From 75ad104e9944a849b436e70bad46301ae0d7aebe Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 15 Jul 2022 11:27:36 -0400 Subject: [PATCH] Apprise @notify decorator/plugin support (#625) --- README.md | 182 +++- apprise/Apprise.py | 46 +- apprise/AppriseAsset.py | 15 +- apprise/AppriseAttachment.py | 9 +- apprise/AppriseConfig.py | 20 +- apprise/URLBase.py | 16 +- apprise/__init__.py | 5 + apprise/attachment/__init__.py | 22 +- apprise/cli.py | 63 +- apprise/common.py | 45 + apprise/config/ConfigBase.py | 52 +- apprise/config/__init__.py | 12 +- apprise/decorators/CustomNotifyPlugin.py | 227 +++++ apprise/decorators/__init__.py | 30 + apprise/decorators/notify.py | 123 +++ apprise/plugins/NotifyBase.py | 9 + apprise/plugins/NotifyFCM/__init__.py | 15 +- apprise/plugins/NotifyWindows.py | 4 +- apprise/plugins/__init__.py | 36 +- apprise/utils.py | 396 ++++++++- packaging/man/apprise.1 | 45 +- packaging/man/apprise.md | 46 +- test/helpers/__init__.py | 2 + test/helpers/module.py | 66 ++ test/test_api.py | 56 +- test/test_apprise_attachments.py | 4 +- test/test_apprise_config.py | 4 +- test/{test_utils.py => test_apprise_utils.py} | 807 +++++++++++++++++- test/test_asyncio.py | 6 +- test/test_attach_http.py | 4 +- test/test_cli.py | 400 ++++++++- test/test_config_http.py | 4 +- test/test_decorator_notify.py | 310 +++++++ test/test_plugin_glib.py | 49 +- test/test_plugin_gnome.py | 40 +- test/test_plugin_macosx.py | 33 +- test/test_plugin_ntfy.py | 8 +- 37 files changed, 2852 insertions(+), 359 deletions(-) create mode 100644 apprise/decorators/CustomNotifyPlugin.py create mode 100644 apprise/decorators/__init__.py create mode 100644 apprise/decorators/notify.py create mode 100644 test/helpers/module.py rename test/{test_utils.py => test_apprise_utils.py} (69%) create mode 100644 test/test_decorator_notify.py diff --git a/README.md b/README.md index 0af52a5d..423c6287 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,32 @@ System Administrators and DevOps who wish to send a notification now no longer n [![CodeCov Status](https://codecov.io/github/caronc/apprise/branch/master/graph/badge.svg)](https://codecov.io/github/caronc/apprise) [![PyPi](https://img.shields.io/pypi/dm/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/) -## Supported Notifications +# Table of Contents + +* [Supported Notifications](#supported-notifications) + * [Productivity Based Notifications](#productivity-based-notifications) + * [SMS Notifications](#sms-notifications) + * [Desktop Notifications](#desktop-notifications) + * [Email Notifications](#email-notifications) + * [Custom Notifications](#custom-notifications) +* [Installation](#installation) +* [Command Line Usage](#command-line-usage) + * [Configuration Files](#cli-configuration-files) + * [File Attachments](#cli-file-attachments) + * [Loading Custom Notifications/Hooks](#cli-loading-custom-notificationshooks) +* [Developer API Usage](#developer-api-usage) + * [Configuration Files](#api-configuration-files) + * [File Attachments](#api-file-attachments) + * [Loading Custom Notifications/Hooks](#api-loading-custom-notificationshooks) +* [More Supported Links and Documentation](#want-to-learn-more) + + +# Supported Notifications + The section identifies all of the services supported by this library. [Check out the wiki for more information on the supported modules here](https://github.com/caronc/apprise/wiki). -### Popular Notification Services +## Productivity Based Notifications + The table below identifies the services this tool supports and some example service urls you need to use in order to take advantage of it. Click on any of the services listed below to get more details on how you can configure Apprise to access them. | Notification Service | Service ID | Default Port | Example Syntax | @@ -99,8 +121,8 @@ The table below identifies the services this tool supports and some example serv | [Webex Teams (Cisco)](https://github.com/caronc/apprise/wiki/Notify_wxteams) | wxteams:// | (TCP) 443 | wxteams://Token | [Zulip Chat](https://github.com/caronc/apprise/wiki/Notify_zulip) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Stream
zulip://botname@Organization/Token/Email +## SMS Notifications -### SMS Notification Support | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN @@ -111,11 +133,13 @@ The table below identifies the services this tool supports and some example serv | [Kavenegar](https://github.com/caronc/apprise/wiki/Notify_kavenegar) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [MessageBird](https://github.com/caronc/apprise/wiki/Notify_messagebird) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [MSG91](https://github.com/caronc/apprise/wiki/Notify_msg91) | msg91:// | (TCP) 443 | msg91://AuthKey/ToPhoneNo
msg91://SenderID@AuthKey/ToPhoneNo
msg91://AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [Signal API](https://github.com/caronc/apprise/wiki/Notify_signal) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Vonage](https://github.com/caronc/apprise/wiki/Notify_nexmo) (formerly Nexmo) | nexmo:// | (TCP) 443 | nexmo://ApiKey:ApiSecret@FromPhoneNo
nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ -## Desktop Notification Support +## Desktop Notifications + | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Linux DBus Notifications](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// @@ -123,7 +147,8 @@ The table below identifies the services this tool supports and some example serv | [MacOS X Notifications](https://github.com/caronc/apprise/wiki/Notify_macosx) | macosx:// | n/a | macosx:// | [Windows Notifications](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows:// -### Email Support +## Email Notifications + | Service ID | Default Port | Example Syntax | | ---------- | ------------ | -------------- | | [mailto://](https://github.com/caronc/apprise/wiki/Notify_email) | (TCP) 25 | mailto://userid:pass@domain.com
mailto://domain.com?user=userid&pass=password
mailto://domain.com:2525?user=userid&pass=password
mailto://user@gmail.com&pass=password
mailto://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com
mailto://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply @@ -131,20 +156,37 @@ The table below identifies the services this tool supports and some example serv Apprise have some email services built right into it (such as yahoo, fastmail, hotmail, gmail, etc) that greatly simplify the mailto:// service. See more details [here](https://github.com/caronc/apprise/wiki/Notify_email). -### Custom Notifications +## Custom Notifications + | Post Method | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Form](https://github.com/caronc/apprise/wiki/Notify_Custom_Form) | form:// or form:// | (TCP) 80 or 443 | form://hostname
form://user@hostname
form://user:password@hostname:port
form://hostname/a/path/to/post/to | [JSON](https://github.com/caronc/apprise/wiki/Notify_Custom_JSON) | json:// or jsons:// | (TCP) 80 or 443 | json://hostname
json://user@hostname
json://user:password@hostname:port
json://hostname/a/path/to/post/to | [XML](https://github.com/caronc/apprise/wiki/Notify_Custom_XML) | xml:// or xmls:// | (TCP) 80 or 443 | xml://hostname
xml://user@hostname
xml://user:password@hostname:port
xml://hostname/a/path/to/post/to -## Installation +# Installation + The easiest way is to install this package is from pypi: ```bash pip install apprise ``` -## Command Line -A small command line tool is also provided with this package called *apprise*. If you know the server url's you wish to notify, you can simply provide them all on the command line and send your notifications that way: + +Apprise is also packaged as an RPM and available through [EPEL](https://docs.fedoraproject.org/en-US/epel/) supporting CentOS, Redhat, Rocky, Oracle Linux, etc. +```bash +# Follow instructions on https://docs.fedoraproject.org/en-US/epel +# to get your system connected up to EPEL and then: +# Redhat/CentOS 7.x users +yum install apprise + +# Redhat/CentOS 8.x+ and/or Fedora Users +dnf install apprise +``` + +You can also check out the [Graphical version of Apprise](https://github.com/caronc/apprise-api) to centralize your configuration and notifications through a managable webpage. + +# Command Line Usage + +A small command line interface (CLI) tool is also provided with this package called *apprise*. If you know the server url's you wish to notify, you can simply provide them all on the command line and send your notifications that way: ```bash # Send a notification to as many servers as you want # as you can easily chain one after another (the -vv provides some @@ -163,16 +205,23 @@ uptime | apprise -vv \ 'discord:///4174216298/JHMHI8qBe7bk2ZwO5U711o3dV_js' ``` -### Configuration Files -No one wants to put their credentials out for everyone to see on the command line. No problem: *apprise* also supports configuration files. It can handle both a specific [YAML format](https://github.com/caronc/apprise/wiki/config_yaml) or a very simple [TEXT format](https://github.com/caronc/apprise/wiki/config_text). You can also pull these configuration files via an HTTP query too! You can read more about the expected structure of the configuration files [here](https://github.com/caronc/apprise/wiki/config). +## CLI Configuration Files + +No one wants to put their credentials out for everyone to see on the command line. No problem *apprise* also supports configuration files. It can handle both a specific [YAML format](https://github.com/caronc/apprise/wiki/config_yaml) or a very simple [TEXT format](https://github.com/caronc/apprise/wiki/config_text). You can also pull these configuration files via an HTTP query too! You can read more about the expected structure of the configuration files [here](https://github.com/caronc/apprise/wiki/config). ```bash -# By default if no url or configuration is specified aprise will attempt to load -# configuration files (if present): +# By default if no url or configuration is specified apprise will attempt to load +# configuration files (if present) from: # ~/.apprise # ~/.apprise.yml # ~/.config/apprise # ~/.config/apprise.yml +# Also a subdirectory handling allows you to leverage plugins +# ~/.apprise/apprise +# ~/.apprise/apprise.yml +# ~/.config/apprise/apprise +# ~/.config/apprise/apprise.yml + # Windows users can store their default configuration files here: # %APPDATA%/Apprise/apprise # %APPDATA%/Apprise/apprise.yml @@ -194,7 +243,8 @@ apprise -vv -t 'my title' -b 'my notification body' \ --config=https://localhost/my/apprise/config ``` -### Attaching Files +## CLI File Attachments + Apprise also supports file attachments too! Specify as many attachments to a notification as you want. ```bash # Send a funny image you found on the internet to a colleague: @@ -211,7 +261,52 @@ apprise -vv --title 'system crash' \ --tag devteam ``` -## Developers +## CLI Loading Custom Notifications/Hooks + +To create your own custom `schema://` hook so that you can trigger your own custom code, +simply include the `@notify` decorator to wrap your function. +```python +from apprise.decorators import notify +# +# The below assumes you want to catch foobar:// calls: +# +@notify(on="foobar", name="My Custom Foobar Plugin") +def my_custom_notification_wrapper(body, title, notify_type, *args, **kwargs): + """My custom notification function that triggers on all foobar:// calls + """ + # Write all of your code here... as an example... + print("{}: {} - {}".format(notify_type.upper(), title, body)) + + # Returning True/False is a way to relay your status back to Apprise. + # Returning nothing (None by default) is always interpreted as a Success +``` + +Once you've defined your custom hook, you just need to tell Apprise where it is at runtime. +```bash +# By default if no plugin path is specified apprise will attempt to load +# all plugin files (if present) from the following directory paths: +# ~/.apprise/apprise/plugins +# ~/.config/apprise/plugins + +# Windows users can store their default plugin files in these directories: +# %APPDATA%/Apprise/plugins +# %LOCALAPPDATA%/Apprise/plugins + +# If you placed your plugin file within one of the directories already defined +# above, then your call simply needs to look like: +apprise -vv --title 'custom override' \ + --body 'the body of my message' \ + foobar:\\ + +# However you can over-ride the path like so +apprise -vv --title 'custom override' \ + --body 'the body of my message' \ + --plugin-path /path/to/my/plugin.py \ + foobar:\\ +``` + +# Developer API Usage + To send a notification from within your python application, just do the following: ```python import apprise @@ -234,7 +329,8 @@ apobj.notify( ) ``` -### Configuration Files +## API Configuration Files + Developers need access to configuration files too. The good news is their use just involves declaring another object (called *AppriseConfig*) that the *Apprise* object can ingest. You can also freely mix and match config and notification entries as often as you wish! You can read more about the expected structure of the configuration files [here](https://github.com/caronc/apprise/wiki/config). ```python import apprise @@ -286,7 +382,8 @@ apobj.notify( ) ``` -### Attaching Files +## API File Attachments + Attachments are very easy to send using the Apprise API: ```python import apprise @@ -343,7 +440,56 @@ apobj.notify( ) ``` -## Want To Learn More? +## API Loading Custom Notifications/Hooks + +By default, no custom plugins are loaded at all for those building from within the Apprise API. +It's at the developers discretion to load custom modules. But should you choose to do so, it's as easy +as including the path reference in the `AppriseAsset()` object prior to the initialization of your `Apprise()` +instance. + +For example: +```python +from apprise import Apprise +from apprise import AppriseAsset + +# Prepare your Asset object so that you can enable the custom plugins to +# be loaded for your instance of Apprise... +asset = AppriseAsset(plugin_paths="/path/to/scan") + +# OR You can also generate scan more then one file too: +asset = AppriseAsset( + plugin_paths=[ + # Iterate over all python libraries found in the root of the + # specified path. This is NOT a recursive (directory) scan; only + # the first level is parsed. HOWEVER, if a directory containing + # an __init__.py is found, it will be included in the load. + "/dir/containing/many/python/libraries", + + # An absolute path to a plugin.py to exclusively load + "/path/to/plugin.py", + + # if you point to a directory that has an __init__.py file found in + # it, then only that file is loaded (it's similar to point to a + # absolute .py file. Hence, there is no (level 1) scanning at all + # within the directory specified. + "/path/to/dir/library" +) + +# Now that we've got our asset, we just work with our Apprise object as we +# normally do +aobj = Apprise(asset=asset) + +# If our new custom `foobar://` library was loaded (presuming we prepared +# one like in the examples above). then you would be able to safely add it +# into Apprise at this point +aobj.add('foobar://') + +# Send our notification out through our foobar:// +aobj.notify("test") +``` + +# Want To Learn More? + If you're interested in reading more about this and other methods on how to customize your own notifications, please check out the following links: * 📣 [Using the CLI](https://github.com/caronc/apprise/wiki/CLI_Usage) * 🛠️ [Development API](https://github.com/caronc/apprise/wiki/Development_API) diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 66395a3e..c0ccb900 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -26,16 +26,13 @@ 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 @@ -141,7 +138,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 +161,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 +181,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 +198,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: # @@ -305,7 +304,7 @@ 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. @@ -323,7 +322,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,13 +337,14 @@ 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. @@ -472,9 +472,10 @@ class Apprise(object): status = Apprise._notifyhandler(server, **kwargs) 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. @@ -485,7 +486,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) @@ -641,7 +642,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 +651,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 diff --git a/apprise/AppriseAsset.py b/apprise/AppriseAsset.py index cd732483..58e36c90 100644 --- a/apprise/AppriseAsset.py +++ b/apprise/AppriseAsset.py @@ -30,6 +30,7 @@ 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): @@ -39,6 +40,10 @@ class AppriseAsset(object): 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' @@ -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/apprise/AppriseAttachment.py b/apprise/AppriseAttachment.py index 37d2c090..bbb37d69 100644 --- a/apprise/AppriseAttachment.py +++ b/apprise/AppriseAttachment.py @@ -31,6 +31,7 @@ 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 @@ -225,13 +226,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 +252,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 +262,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 diff --git a/apprise/AppriseConfig.py b/apprise/AppriseConfig.py index 64d01738..5da17589 100644 --- a/apprise/AppriseConfig.py +++ b/apprise/AppriseConfig.py @@ -30,9 +30,7 @@ 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 @@ -267,7 +265,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 +284,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 +302,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 +333,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 +367,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 +378,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 diff --git a/apprise/URLBase.py b/apprise/URLBase.py index bc653b97..4b23fcc5 100644 --- a/apprise/URLBase.py +++ b/apprise/URLBase.py @@ -34,16 +34,15 @@ 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 .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 @@ -497,17 +496,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): diff --git a/apprise/__init__.py b/apprise/__init__.py index 8c65691b..bb991a7d 100644 --- a/apprise/__init__.py +++ b/apprise/__init__.py @@ -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/apprise/attachment/__init__.py b/apprise/attachment/__init__.py index da6dbbf1..81eb406c 100644 --- a/apprise/attachment/__init__.py +++ b/apprise/attachment/__init__.py @@ -29,9 +29,7 @@ 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__ = [] @@ -91,28 +89,28 @@ 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 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 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/apprise/cli.py b/apprise/cli.py index 70458c92..9bb6262b 100644 --- a/apprise/cli.py +++ b/apprise/cli.py @@ -32,6 +32,7 @@ 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 +41,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 +62,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 +128,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 +182,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 +256,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 @@ -248,6 +278,9 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, # incase there are problems in the future where it's better that # everything run sequentially/syncronously instead. async_mode=disable_async is not True, + + # Load our plugins + plugin_paths=plugin_path, ) # Create our Apprise object @@ -284,11 +317,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 +347,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 +365,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 +414,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/apprise/common.py b/apprise/common.py index d1f43ada..45b4ec7b 100644 --- a/apprise/common.py +++ b/apprise/common.py @@ -24,6 +24,51 @@ # THE SOFTWARE. +# 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(object): """ A simple mapping of notification types most commonly used with diff --git a/apprise/config/ConfigBase.py b/apprise/config/ConfigBase.py index d5136faf..840d4835 100644 --- a/apprise/config/ConfigBase.py +++ b/apprise/config/ConfigBase.py @@ -30,17 +30,14 @@ 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 +54,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 +67,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() @@ -142,7 +139,7 @@ class ConfigBase(URLBase): # 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) @@ -230,7 +227,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 +238,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 +246,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 +278,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 +378,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 +456,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 +471,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 +492,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 +617,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( @@ -881,7 +880,7 @@ class ConfigBase(URLBase): 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 +892,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,7 +926,7 @@ class ConfigBase(URLBase): # Grab our first item _results = results.pop(0) - if _results['schema'] not in plugins.SCHEMA_MAP: + 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 ' @@ -970,7 +969,8 @@ 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( @@ -1023,7 +1023,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: @@ -1068,7 +1068,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()): diff --git a/apprise/config/__init__.py b/apprise/config/__init__.py index 353123e9..6b1781b2 100644 --- a/apprise/config/__init__.py +++ b/apprise/config/__init__.py @@ -29,9 +29,7 @@ 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__ = [] @@ -113,16 +111,16 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'): # 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/apprise/decorators/CustomNotifyPlugin.py b/apprise/decorators/CustomNotifyPlugin.py new file mode 100644 index 00000000..fa487558 --- /dev/null +++ b/apprise/decorators/CustomNotifyPlugin.py @@ -0,0 +1,227 @@ +# -*- 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. +import six +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}://', + ) + + # Our default arguments will get populated after we're instatiated by the + # wrapper class + _default_args = {} + + def __init__(self, **kwargs): + """ + Our initialization + + """ + super(CustomNotifyPlugin, self).__init__(**kwargs) + + # Apply our updates based on what was parsed + 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) + + @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, six.string_types): + 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 + default_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, six.string_types) \ + 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 + _default_args = 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/apprise/decorators/__init__.py b/apprise/decorators/__init__.py new file mode 100644 index 00000000..a6ef9662 --- /dev/null +++ b/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/apprise/decorators/notify.py b/apprise/decorators/notify.py new file mode 100644 index 00000000..3705e870 --- /dev/null +++ b/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/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 54e89790..ca5a51a4 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -59,6 +59,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/apprise/plugins/NotifyFCM/__init__.py b/apprise/plugins/NotifyFCM/__init__.py index a6f801c8..fc47984f 100644 --- a/apprise/plugins/NotifyFCM/__init__.py +++ b/apprise/plugins/NotifyFCM/__init__.py @@ -53,6 +53,7 @@ from ...common import NotifyType from ...utils import validate_regex from ...utils import parse_list from ...utils import parse_bool +from ...utils import dict_full_update from ...common import NotifyImageSize from ...AppriseAttachment import AppriseAttachment from ...AppriseLocale import gettext_lazy as _ @@ -450,17 +451,9 @@ class NotifyFCM(NotifyBase): "FCM recipient %s parsed as a device token", recipient) - # - # Apply our priority configuration (if set) - # - def merge(d1, d2): - for k in d2: - if k in d1 and isinstance(d1[k], dict) \ - and isinstance(d2[k], dict): - merge(d1[k], d2[k]) - else: - d1[k] = d2[k] - merge(payload, self.priority.payload()) + # A more advanced dict.update() that recursively includes + # sub-dictionaries as well + dict_full_update(payload, self.priority.payload()) self.logger.debug( 'FCM %s POST URL: %s (cert_verify=%r)', diff --git a/apprise/plugins/NotifyWindows.py b/apprise/plugins/NotifyWindows.py index b03d375b..1c599e14 100644 --- a/apprise/plugins/NotifyWindows.py +++ b/apprise/plugins/NotifyWindows.py @@ -203,9 +203,9 @@ class NotifyWindows(NotifyBase): self.logger.info('Sent Windows notification.') - except Exception: + except Exception as e: self.logger.warning('Failed to send Windows notification.') - self.logger.exception('Windows Exception') + self.logger.debug('Windows Exception: {}', str(e)) return False return True diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index 177c0e24..91131260 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -41,6 +41,7 @@ from ..common import NotifyImageSize from ..common import NOTIFY_IMAGE_SIZES from ..common import NotifyType from ..common import NOTIFY_TYPES +from .. import common from ..utils import parse_list from ..utils import cwe312_url from ..utils import GET_SCHEMA_RE @@ -48,9 +49,6 @@ from ..logger import logger from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import LazyTranslation -# Maintains a mapping of all of the Notification services -SCHEMA_MAP = {} - __all__ = [ # Reference 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', @@ -63,10 +61,6 @@ __all__ = [ 'url_to_dict', ] -# 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 -MODULE_MAP = {} - # Load our Lookup Matrix def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'): @@ -109,12 +103,12 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'): # Filter out non-notification modules continue - elif plugin_name in MODULE_MAP: + elif plugin_name in common.NOTIFY_MODULE_MAP: # we're already handling this object continue # Add our plugin name to our module map - MODULE_MAP[plugin_name] = { + common.NOTIFY_MODULE_MAP[plugin_name] = { 'plugin': plugin, 'module': module, } @@ -150,16 +144,16 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'): # map our schema to our plugin for schema in schemas: - if schema in SCHEMA_MAP: + if schema in common.NOTIFY_SCHEMA_MAP: logger.error( "Notification schema ({}) mismatch detected - {} to {}" - .format(schema, SCHEMA_MAP[schema], plugin)) + .format(schema, common.NOTIFY_SCHEMA_MAP[schema], plugin)) continue # Assign plugin - SCHEMA_MAP[schema] = plugin + common.NOTIFY_SCHEMA_MAP[schema] = plugin - return SCHEMA_MAP + return common.NOTIFY_SCHEMA_MAP # Reset our Lookup Matrix @@ -170,10 +164,10 @@ def __reset_matrix(): """ # Reset our schema map - SCHEMA_MAP.clear() + common.NOTIFY_SCHEMA_MAP.clear() # Iterate over our module map so we can clear out our __all__ and globals - for plugin_name in MODULE_MAP.keys(): + for plugin_name in common.NOTIFY_MODULE_MAP.keys(): # Clear out globals del globals()[plugin_name] @@ -181,7 +175,7 @@ def __reset_matrix(): __all__.remove(plugin_name) # Clear out our module map - MODULE_MAP.clear() + common.NOTIFY_MODULE_MAP.clear() # Dynamically build our schema base @@ -550,14 +544,14 @@ def url_to_dict(url, secure_logging=True): # Ensure our schema is always in lower case schema = schema.group('schema').lower() - if schema not in SCHEMA_MAP: + if schema not in common.NOTIFY_SCHEMA_MAP: # Give the user the benefit of the doubt that the user may be using # one of the URLs provided to them by their notification service. # Before we fail for good, just scan all the plugins that support the # native_url() parse function results = \ next((r['plugin'].parse_native_url(_url) - for r in MODULE_MAP.values() + for r in common.NOTIFY_MODULE_MAP.values() if r['plugin'].parse_native_url(_url) is not None), None) @@ -572,14 +566,14 @@ def url_to_dict(url, secure_logging=True): else: # 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.NOTIFY_SCHEMA_MAP[schema].parse_url(_url) if not results: logger.error('Unparseable {} URL {}'.format( - SCHEMA_MAP[schema].service_name, loggable_url)) + common.NOTIFY_SCHEMA_MAP[schema].service_name, loggable_url)) return None logger.trace('{} URL {} unpacked as:{}{}'.format( - SCHEMA_MAP[schema].service_name, url, + common.NOTIFY_SCHEMA_MAP[schema].service_name, url, os.linesep, os.linesep.join( ['{}="{}"'.format(k, v) for k, v in results.items()]))) diff --git a/apprise/utils.py b/apprise/utils.py index 31647ce7..af839cb8 100644 --- a/apprise/utils.py +++ b/apprise/utils.py @@ -25,26 +25,78 @@ import re import six +import sys import json import contextlib import os +import hashlib from itertools import chain from os.path import expanduser from functools import reduce -from .common import MATCH_ALL_TAG -from .common import MATCH_ALWAYS_TAG +from . import common +from .logger import logger try: # Python 2.7 from urllib import unquote from urllib import quote from urlparse import urlparse + from urllib import urlencode as _urlencode + + import imp + + def import_module(path, name): + """ + Load our module based on path + """ + try: + return imp.load_source(name, path) + + except Exception as e: + logger.debug( + 'Custom module exception raised from %s (name=%s) %s', + path, name, str(e)) + + return None except ImportError: - # Python 3.x + # Python 3.5+ from urllib.parse import unquote from urllib.parse import quote from urllib.parse import urlparse + from urllib.parse import urlencode as _urlencode + + import importlib.util + + def import_module(path, name): + """ + Load our module based on path + """ + # if path.endswith('test_module_detection0/a/hook.py'): + # import pdb + # pdb.set_trace() + + spec = importlib.util.spec_from_file_location(name, path) + try: + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + + spec.loader.exec_module(module) + + except Exception as e: + # module isn't loadable + del sys.modules[name] + module = None + + logger.debug( + 'Custom module exception raised from %s (name=%s) %s', + path, name, str(e)) + + return module + +# Hash of all paths previously scanned so we don't waste effort/overhead doing +# it again +PATHS_PREVIOUSLY_SCANNED = set() # URL Indexing Table for returns via parse_url() # The below accepts and scans for: @@ -107,6 +159,13 @@ NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r'^:(?P.*)\s*') # Used for attempting to acquire the schema if the URL can't be parsed. GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{2,9})://.*$', re.I) +# Used for validating that a provided entry is indeed a schema +# this is slightly different then the GET_SCHEMA_RE above which +# insists the schema is only valid with a :// entry. this one +# extrapolates the individual entries +URL_DETAILS_RE = re.compile( + r'\s*(?P[a-z0-9]{2,9})(://(?P.*))?$', re.I) + # Regular expression based and expanded from: # http://www.regular-expressions.info/email.html # Extended to support colon (:) delimiter for parsing names from the URL @@ -477,16 +536,14 @@ def tidy_path(path): # Windows path = TIDY_WIN_PATH_RE.sub('\\1', path.strip()) # Linux - path = TIDY_NUX_PATH_RE.sub('\\1', path.strip()) + path = TIDY_NUX_PATH_RE.sub('\\1', path) - # Linux Based Trim - path = TIDY_NUX_TRIM_RE.sub('\\1', path.strip()) - # Windows Based Trim - path = expanduser(TIDY_WIN_TRIM_RE.sub('\\1', path.strip())) + # Windows Based (final) Trim + path = expanduser(TIDY_WIN_TRIM_RE.sub('\\1', path)) return path -def parse_qsd(qs): +def parse_qsd(qs, simple=False): """ Query String Dictionary Builder @@ -505,6 +562,9 @@ def parse_qsd(qs): This function returns a result object that fits with the apprise expected parameters (populating the 'qsd' portion of the dictionary + + if simple is set to true, then a ONE dictionary is returned and is not + sub-parsed for additional elements """ # Our return result set: @@ -520,7 +580,7 @@ def parse_qsd(qs): 'qsd+': {}, 'qsd-': {}, 'qsd:': {}, - } + } if not simple else {'qsd': {}} pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')] for name_value in pairs: @@ -547,6 +607,10 @@ def parse_qsd(qs): # content is always made lowercase for easy indexing result['qsd'][key.lower().strip()] = val + if simple: + # move along + continue + # Check for tokens that start with a addition/plus symbol (+) k = NOTIFY_CUSTOM_ADD_TOKENS.match(key) if k is not None: @@ -568,7 +632,8 @@ def parse_qsd(qs): return result -def parse_url(url, default_schema='http', verify_host=True, strict_port=False): +def parse_url(url, default_schema='http', verify_host=True, strict_port=False, + simple=False): """A function that greatly simplifies the parsing of a url specified by the end user. @@ -590,6 +655,39 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): The function returns a simple dictionary with all of the parsed content within it and returns 'None' if the content could not be extracted. + + The output of 'http://hostname' would look like: + { + 'schema': 'http', + 'url': 'http://hostname', + 'host': 'hostname', + + 'user': None, + 'password': None, + 'port': None, + 'fullpath': None, + 'path': None, + 'query': None, + + 'qsd': {}, + + 'qsd+': {}, + 'qsd-': {}, + 'qsd:': {} + } + + The simple switch cleans the dictionary response to only include the + fields that were detected. + + The output of 'http://hostname' with the simple flag set would look like: + { + 'schema': 'http', + 'url': 'http://hostname', + 'host': 'hostname', + } + + If the URL can't be parsed then None is returned + """ if not isinstance(url, six.string_types): @@ -628,7 +726,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): 'qsd+': {}, 'qsd-': {}, 'qsd:': {}, - } + } if not simple else {} qsdata = '' match = VALID_URL_RE.search(url) @@ -648,7 +746,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): # Parse Query Arugments ?val=key&key=val # while ensuring that all keys are lowercase if qsdata: - result.update(parse_qsd(qsdata)) + result.update(parse_qsd(qsdata, simple=simple)) # Now do a proper extraction of data; http:// is just substitued in place # to allow urlparse() to function as expected, we'll swap this back to the @@ -671,8 +769,12 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): pass if not result['fullpath']: - # Default - result['fullpath'] = None + if not simple: + # Default + result['fullpath'] = None + else: + # Remove entry + del result['fullpath'] else: # Using full path, extract query from path @@ -680,7 +782,11 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): result['path'] = match.group('path') result['query'] = match.group('query') if not result['query']: - result['query'] = None + if not simple: + result['query'] = None + else: + del result['query'] + try: (result['user'], result['host']) = \ re.split(r'[@]+', result['host'])[:2] @@ -690,7 +796,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): # and it's already assigned pass - if result['user'] is not None: + if result.get('user') is not None: try: (result['user'], result['password']) = \ re.split(r'[:]+', result['user'])[:2] @@ -724,6 +830,9 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): # Invalid Host Specified return None + # Acquire our port (if defined) + _port = result.get('port') + if verify_host: # Verify and Validate our hostname result['host'] = is_hostname(result['host']) @@ -733,15 +842,14 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): return None # Max port is 65535 and min is 1 - if isinstance(result['port'], int) and not (( + if isinstance(_port, int) and not (( not strict_port or ( - strict_port and - result['port'] > 0 and result['port'] <= 65535))): + strict_port and _port > 0 and _port <= 65535))): # An invalid port was specified return None - elif pmatch and not isinstance(result['port'], int): + elif pmatch and not isinstance(_port, int): if strict_port: # Store port result['port'] = pmatch.group('port').strip() @@ -754,26 +862,34 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False): # Re-assemble cleaned up version of the url result['url'] = '%s://' % result['schema'] - if isinstance(result['user'], six.string_types): + if isinstance(result.get('user'), six.string_types): result['url'] += result['user'] - if isinstance(result['password'], six.string_types): + if isinstance(result.get('password'), six.string_types): result['url'] += ':%s@' % result['password'] else: result['url'] += '@' result['url'] += result['host'] - if result['port'] is not None: + if result.get('port') is not None: try: result['url'] += ':%d' % result['port'] except TypeError: result['url'] += ':%s' % result['port'] - if result['fullpath']: + elif 'port' in result and simple: + # Eliminate empty fields + del result['port'] + + if result.get('fullpath'): result['url'] += result['fullpath'] + if simple and not result['host']: + # simple mode does not carry over empty host names + del result['host'] + return result @@ -960,6 +1076,81 @@ def parse_urls(*args, **kwargs): return result +def url_assembly(**kwargs): + """ + This function reverses the parse_url() function by taking in the provided + result set and re-assembling a URL + + """ + # Determine Authentication + auth = '' + if kwargs.get('user') is not None and \ + kwargs.get('password') is not None: + + auth = '{user}:{password}@'.format( + user=quote(kwargs.get('user'), safe=''), + password=quote(kwargs.get('password'), safe=''), + ) + + elif kwargs.get('user') is not None: + auth = '{user}@'.format( + user=quote(kwargs.get('user'), safe=''), + ) + + return '{schema}://{auth}{hostname}{port}{fullpath}{params}'.format( + schema='' if not kwargs.get('schema') else kwargs.get('schema'), + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname='' if not kwargs.get('host') else kwargs.get('host', ''), + port='' if not kwargs.get('port') + else ':{}'.format(kwargs.get('port')), + fullpath=quote(kwargs.get('fullpath', ''), safe='/'), + params='' if not kwargs.get('qsd') + else '?{}'.format(urlencode(kwargs.get('qsd'))), + ) + + +def urlencode(query, doseq=False, safe='', encoding=None, errors=None): + """Convert a mapping object or a sequence of two-element tuples + + Wrapper to Python's unquote while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + The resulting string is a series of key=value pairs separated by '&' + characters, where both key and value are quoted using the quote() + function. + + Note: If the dictionary entry contains an entry that is set to None + it is not included in the final result set. If you want to + pass in an empty variable, set it to an empty string. + + Args: + query (str): The dictionary to encode + doseq (:obj:`bool`, optional): Handle sequences + safe (:obj:`str`): non-ascii characters and URI specific ones that + you do not wish to escape (if detected). Setting this string + to an empty one causes everything to be escaped. + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The escaped parameters returned as a string + """ + # Tidy query by eliminating any records set to None + _query = {k: v for (k, v) in query.items() if v is not None} + try: + # Python v3.x + return _urlencode( + _query, doseq=doseq, safe=safe, encoding=encoding, + errors=errors) + + except TypeError: + # Python v2.7 + return _urlencode(_query) + + def parse_list(*args): """ Take a string list and break it into a delimited @@ -998,8 +1189,8 @@ def parse_list(*args): return sorted([x for x in filter(bool, list(set(result)))]) -def is_exclusive_match(logic, data, match_all=MATCH_ALL_TAG, - match_always=MATCH_ALWAYS_TAG): +def is_exclusive_match(logic, data, match_all=common.MATCH_ALL_TAG, + match_always=common.MATCH_ALWAYS_TAG): """ The data variable should always be a set of strings that the logic can be @@ -1386,3 +1577,154 @@ def remove_suffix(value, suffix): Removes a suffix from the end of a string. """ return value[:-len(suffix)] if value.endswith(suffix) else value + + +def module_detection(paths, cache=True): + """ + Iterates over a defined path for apprise decorators to load such as + @notify. + + """ + + # A simple restriction that we don't allow periods in the filename at all + # so it can't be hidden (Linux OS's) and it won't conflict with Python + # path naming. This also prevents us from loading any python file that + # starts with an underscore or dash + # We allow __init__.py as well + module_re = re.compile( + r'^(?P[_a-z0-9][a-z0-9._-]+)?(\.py)?$', re.I) + + if isinstance(paths, six.string_types): + paths = [paths, ] + + if not paths or not isinstance(paths, (tuple, list)): + # We're done + return None + + def _import_module(path): + # Since our plugin name can conflict (as a module) with another + # we want to generate random strings to avoid steping on + # another's namespace + module_name = hashlib.sha1(path.encode('utf-8')).hexdigest() + module_pyname = "{prefix}.{name}".format( + prefix='apprise.custom.module', name=module_name) + + if module_pyname in common.NOTIFY_CUSTOM_MODULE_MAP: + # First clear out existing entries + for schema in common.\ + NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify'] \ + .keys(): + # Remove any mapped modules to this file + del common.NOTIFY_SCHEMA_MAP[schema] + + # Reset + del common.NOTIFY_CUSTOM_MODULE_MAP[module_pyname] + + # Load our module + module = import_module(path, module_pyname) + if not module: + # No problem, we can't use this object + logger.warning('Failed to load custom module: %s', _path) + return None + + # Print our loaded modules if any + if module_pyname in common.NOTIFY_CUSTOM_MODULE_MAP: + logger.debug( + 'Loaded custom module: %s (name=%s)', + _path, module_name) + + for schema, meta in common.\ + NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify']\ + .items(): + + logger.info('Loaded custom notification: %s://', schema) + else: + # The code reaches here if we successfully loaded the Python + # module but no hooks/triggers were found. So we can safely + # just remove/ignore this entry + del sys.modules[module_pyname] + return None + + # end of _import_module() + return None + + for _path in paths: + path = os.path.abspath(os.path.expanduser(_path)) + if (cache and path in PATHS_PREVIOUSLY_SCANNED) \ + or not os.path.exists(path): + # We're done as we've already scanned this + continue + + # Store our path as a way of hashing it has been handled + PATHS_PREVIOUSLY_SCANNED.add(path) + + if os.path.isdir(path) and not \ + os.path.isfile(os.path.join(path, '__init__.py')): + + logger.debug('Scanning for custom plugins in: %s', path) + for entry in os.listdir(path): + re_match = module_re.match(entry) + if not re_match: + # keep going + logger.trace('Plugin Scan: Ignoring %s', entry) + continue + + new_path = os.path.join(path, entry) + if os.path.isdir(new_path): + # Update our path + new_path = os.path.join(path, entry, '__init__.py') + if not os.path.isfile(new_path): + logger.trace( + 'Plugin Scan: Ignoring %s', + os.path.join(path, entry)) + continue + + if not cache or \ + (cache and new_path not in PATHS_PREVIOUSLY_SCANNED): + # Load our module + _import_module(new_path) + + # Add our subdir path + PATHS_PREVIOUSLY_SCANNED.add(new_path) + else: + if os.path.isdir(path): + # This logic is safe to apply because we already validated + # the directories state above; update our path + path = os.path.join(path, '__init__.py') + if cache and path in PATHS_PREVIOUSLY_SCANNED: + continue + + PATHS_PREVIOUSLY_SCANNED.add(path) + + # directly load as is + re_match = module_re.match(os.path.basename(path)) + # must be a match and must have a .py extension + if not re_match or not re_match.group(1): + # keep going + logger.trace('Plugin Scan: Ignoring %s', path) + continue + + # Load our module + _import_module(path) + + return None + + +def dict_full_update(dict1, dict2): + """ + Takes 2 dictionaries (dict1 and dict2) that contain sub-dictionaries and + gracefully merges them into dict1. + + This is similar to: dict1.update(dict2) except that internal dictionaries + are also recursively applied. + """ + def _merge(dict1, dict2): + for k in dict2: + if k in dict1 and isinstance(dict1[k], dict) \ + and isinstance(dict2[k], dict): + _merge(dict1[k], dict2[k]) + else: + dict1[k] = dict2[k] + + _merge(dict1, dict2) + return diff --git a/packaging/man/apprise.1 b/packaging/man/apprise.1 index 0d126755..893c07a3 100644 --- a/packaging/man/apprise.1 +++ b/packaging/man/apprise.1 @@ -1,6 +1,6 @@ .\" generated with Ronn-NG/v0.9.1 .\" http://github.com/apjanke/ronn-ng/tree/0.9.1 -.TH "APPRISE" "1" "November 2021" "" +.TH "APPRISE" "1" "July 2022" "" .SH "NAME" \fBapprise\fR \- Push Notifications that work with just about every platform! .SH "SYNOPSIS" @@ -26,6 +26,10 @@ The Apprise options are as follows: .P \fB\-a\fR, \fB\-\-attach=\fR\fIATTACH\-URL\fR: Specify one or more file attachment locations\. .P +\fB\-P\fR, \fB\-\-plugin\-path=\fR\fIPLUGIN\-PATH\fR: Specify a path to scan for custom notification plugin support\. You can create your own notification by simply creating a Python file that contains the \fB@notify("schema")\fR decorator\. +.P +You can optioanly chose to specify more then one \fB\-\-plugin\-path\fR (\fB\-P\fR) to increase the modules included\. +.P \fB\-n\fR, \fB\-\-notification\-type=\fR\fITYPE\fR: Specify the message type (default=info)\. Possible values are "info", "success", "failure", and "warning"\. .P \fB\-i\fR, \fB\-\-input\-format=\fR\fIFORMAT\fR: Specify the input message format (default=text)\. Possible values are "text", "html", and "markdown"\. @@ -108,16 +112,47 @@ $ apprise \-vv \-t "School Assignment" \-b "See attached" \e \-\-attach=Documents/FinalReport\.docx .fi .IP "" 0 +.SH "CUSTOM PLUGIN/NOTIFICATIONS" +Apprise can additionally allow you to define your own custom \fBschema://\fR entries that you can trigger on and call services you\'ve defined\. +.P +By default \fBapprise\fR looks in the following local locations for custom plugin files and loads them: +.IP "" 4 +.nf +~/\.apprise/plugins +~/\.config/apprise/plugins +.fi +.IP "" 0 +.P +Simply create your own python file with the following bare minimum content in it: from apprise\.decorators import notify +.IP "" 4 +.nf +# This example assumes you want your function to trigger on foobar:// +# references: +@notify(on="foobar", name="My Custom Notification") +def my_wrapper(body, title, notify_type, *args, **kwargs): + + + + # Returning True/False is a way to relay your status back to Apprise\. + # Returning nothing (None by default) is always interpreted as a Success + return True +.fi +.IP "" 0 .SH "CONFIGURATION" A configuration file can be in the format of either \fBTEXT\fR or \fBYAML\fR where [TEXT][textconfig] is the easiest and most ideal solution for most users\. However YAML \fIhttps://github\.com/caronc/apprise/wiki/config_yaml\fR configuration files grants the user a bit more leverage and access to some of the internal features of Apprise\. Reguardless of which format you choose, both provide the users the ability to leverage \fBtagging\fR which adds a more rich and powerful notification environment\. .P Configuration files can be directly referenced via \fBapprise\fR when referencing the \fB\-\-config=\fR (\fB\-c\fR) CLI directive\. You can identify as many as you like on the command line and all of them will be loaded\. You can also point your configuration to a cloud location (by referencing \fBhttp://\fR or \fBhttps://\fR\. By default \fBapprise\fR looks in the following local locations for configuration files and loads them: .IP "" 4 .nf -$ ~/\.apprise -$ ~/\.apprise\.yml -$ ~/\.config/apprise -$ ~/\.config/apprise\.yml +~/\.apprise +~/\.apprise\.yml +~/\.config/apprise +~/\.config/apprise\.yml + +~/\.apprise/apprise +~/\.apprise/apprise\.yaml +~/\.config/apprise/apprise +~/\.config/apprise/apprise\.yaml .fi .IP "" 0 .P diff --git a/packaging/man/apprise.md b/packaging/man/apprise.md index d4271bfe..951c83e9 100644 --- a/packaging/man/apprise.md +++ b/packaging/man/apprise.md @@ -33,6 +33,14 @@ The Apprise options are as follows: `-a`, `--attach=`: Specify one or more file attachment locations. + `-P`, `--plugin-path=`: + Specify a path to scan for custom notification plugin support. + You can create your own notification by simply creating a Python file + that contains the `@notify("schema")` decorator. + + You can optioanly chose to specify more then one **--plugin-path** (**-P**) + to increase the modules included. + `-n`, `--notification-type=`: Specify the message type (default=info). Possible values are "info", "success", "failure", and "warning". @@ -134,6 +142,31 @@ Include an attachment: $ apprise -vv -t "School Assignment" -b "See attached" \ --attach=Documents/FinalReport.docx +## CUSTOM PLUGIN/NOTIFICATIONS +Apprise can additionally allow you to define your own custom **schema://** +entries that you can trigger on and call services you've defined. + +By default **apprise** looks in the following local locations for custom plugin +files and loads them: + + ~/.apprise/plugins + ~/.config/apprise/plugins + +Simply create your own python file with the following bare minimum content in +it: + from apprise.decorators import notify + + # This example assumes you want your function to trigger on foobar:// + # references: + @notify(on="foobar", name="My Custom Notification") + def my_wrapper(body, title, notify_type, *args, **kwargs): + + + + # Returning True/False is a way to relay your status back to Apprise. + # Returning nothing (None by default) is always interpreted as a Success + return True + ## CONFIGURATION A configuration file can be in the format of either **TEXT** or **YAML** where @@ -149,10 +182,15 @@ command line and all of them will be loaded. You can also point your configurat a cloud location (by referencing `http://` or `https://`. By default **apprise** looks in the following local locations for configuration files and loads them: - $ ~/.apprise - $ ~/.apprise.yml - $ ~/.config/apprise - $ ~/.config/apprise.yml + ~/.apprise + ~/.apprise.yml + ~/.config/apprise + ~/.config/apprise.yml + + ~/.apprise/apprise + ~/.apprise/apprise.yaml + ~/.config/apprise/apprise + ~/.config/apprise/apprise.yaml If a default configuration file is referenced in any way by the **apprise** tool, you no longer need to provide it a Service URL. Usage of the **apprise** diff --git a/test/helpers/__init__.py b/test/helpers/__init__.py index 6101bfc8..665f1ba6 100644 --- a/test/helpers/__init__.py +++ b/test/helpers/__init__.py @@ -23,7 +23,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from .rest import AppriseURLTester +from .module import module_reload __all__ = [ 'AppriseURLTester', + 'module_reload', ] diff --git a/test/helpers/module.py b/test/helpers/module.py new file mode 100644 index 00000000..47b3e9b4 --- /dev/null +++ b/test/helpers/module.py @@ -0,0 +1,66 @@ +# -*- 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. + +import re +import os +import sys + +try: + # Python v3.4+ + from importlib import reload +except ImportError: + try: + # Python v3.0-v3.3 + from imp import reload + except ImportError: + # Python v2.7 + pass + + +def module_reload(filename): + """ + + set filename to plugin to be reloaded (for example NotifyGnome.py) + + The following libraries need to be reloaded to prevent + TypeError: super(type, obj): obj must be an instance or subtype of type + This is better explained in this StackOverflow post: + https://stackoverflow.com/questions/31363311/\ + any-way-to-manually-fix-operation-of-\ + super-after-ipython-reload-avoiding-ty + + """ + + module_name = 'apprise.plugins.{}'.format( + re.match(r'^(.+)(\.py)?$', os.path.basename(filename), re.I).group(1)) + + reload(sys.modules['apprise.common']) + reload(sys.modules['apprise.attachment']) + reload(sys.modules['apprise.config']) + reload(sys.modules[module_name]) + reload(sys.modules['apprise.plugins']) + reload(sys.modules['apprise.Apprise']) + reload(sys.modules['apprise.utils']) + reload(sys.modules['apprise']) diff --git a/test/test_api.py b/test/test_api.py index d21a45ec..9f029273 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -45,7 +45,7 @@ from apprise import URLBase from apprise import PrivacyMode from apprise.AppriseLocale import LazyTranslation -from apprise.plugins import SCHEMA_MAP +from apprise import common from apprise.plugins import __load_matrix from apprise.plugins import __reset_matrix from apprise.utils import parse_list @@ -219,10 +219,10 @@ def apprise_test(do_notify): return NotifyBase.parse_url(url, verify_host=False) # Store our bad notification in our schema map - SCHEMA_MAP['bad'] = BadNotification + common.NOTIFY_SCHEMA_MAP['bad'] = BadNotification # Store our good notification in our schema map - SCHEMA_MAP['good'] = GoodNotification + common.NOTIFY_SCHEMA_MAP['good'] = GoodNotification # Just to explain what is happening here, we would have parsed the # url properly but failed when we went to go and create an instance @@ -322,13 +322,13 @@ def apprise_test(do_notify): return '' # Store our bad notification in our schema map - SCHEMA_MAP['throw'] = ThrowNotification + common.NOTIFY_SCHEMA_MAP['throw'] = ThrowNotification # Store our good notification in our schema map - SCHEMA_MAP['fail'] = FailNotification + common.NOTIFY_SCHEMA_MAP['fail'] = FailNotification # Store our good notification in our schema map - SCHEMA_MAP['runtime'] = RuntimeNotification + common.NOTIFY_SCHEMA_MAP['runtime'] = RuntimeNotification for async_mode in (True, False): # Create an Asset object @@ -356,7 +356,7 @@ def apprise_test(do_notify): # Support URL return '' - SCHEMA_MAP['throw'] = ThrowInstantiateNotification + common.NOTIFY_SCHEMA_MAP['throw'] = ThrowInstantiateNotification # Reset our object a.clear() @@ -700,9 +700,9 @@ def test_apprise_schemas(tmpdir): secure_protocol = 'markdowns' # Store our notifications into our schema map - SCHEMA_MAP['text'] = TextNotification - SCHEMA_MAP['html'] = HtmlNotification - SCHEMA_MAP['markdown'] = MarkDownNotification + common.NOTIFY_SCHEMA_MAP['text'] = TextNotification + common.NOTIFY_SCHEMA_MAP['html'] = HtmlNotification + common.NOTIFY_SCHEMA_MAP['markdown'] = MarkDownNotification schemas = URLBase.schemas(TextNotification) assert isinstance(schemas, set) is True @@ -787,9 +787,9 @@ def test_apprise_notify_formats(tmpdir): return '' # Store our notifications into our schema map - SCHEMA_MAP['text'] = TextNotification - SCHEMA_MAP['html'] = HtmlNotification - SCHEMA_MAP['markdown'] = MarkDownNotification + common.NOTIFY_SCHEMA_MAP['text'] = TextNotification + common.NOTIFY_SCHEMA_MAP['html'] = HtmlNotification + common.NOTIFY_SCHEMA_MAP['markdown'] = MarkDownNotification # Test Markdown; the above calls the markdown because our good:// # defined plugin above was defined to default to HTML which triggers @@ -995,7 +995,7 @@ def test_apprise_disabled_plugins(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['na01'] = TestDisabled01Notification + common.NOTIFY_SCHEMA_MAP['na01'] = TestDisabled01Notification class TestDisabled02Notification(NotifyBase): """ @@ -1020,7 +1020,7 @@ def test_apprise_disabled_plugins(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['na02'] = TestDisabled02Notification + common.NOTIFY_SCHEMA_MAP['na02'] = TestDisabled02Notification # Create our Apprise instance a = Apprise() @@ -1078,7 +1078,7 @@ def test_apprise_disabled_plugins(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['good'] = TesEnabled01Notification + common.NOTIFY_SCHEMA_MAP['good'] = TesEnabled01Notification # The last thing we'll simulate is a case where the plugin is just # disabled at a later time long into it's life. this is just to allow @@ -1218,7 +1218,7 @@ def test_apprise_details(): return True # Store our good detail notification in our schema map - SCHEMA_MAP['details'] = TestDetailNotification + common.NOTIFY_SCHEMA_MAP['details'] = TestDetailNotification # This is a made up class that is just used to verify class TestReq01Notification(NotifyBase): @@ -1243,7 +1243,7 @@ def test_apprise_details(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req01'] = TestReq01Notification + common.NOTIFY_SCHEMA_MAP['req01'] = TestReq01Notification # This is a made up class that is just used to verify class TestReq02Notification(NotifyBase): @@ -1273,7 +1273,7 @@ def test_apprise_details(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req02'] = TestReq02Notification + common.NOTIFY_SCHEMA_MAP['req02'] = TestReq02Notification # This is a made up class that is just used to verify class TestReq03Notification(NotifyBase): @@ -1299,7 +1299,7 @@ def test_apprise_details(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req03'] = TestReq03Notification + common.NOTIFY_SCHEMA_MAP['req03'] = TestReq03Notification # This is a made up class that is just used to verify class TestReq04Notification(NotifyBase): @@ -1319,7 +1319,7 @@ def test_apprise_details(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req04'] = TestReq04Notification + common.NOTIFY_SCHEMA_MAP['req04'] = TestReq04Notification # This is a made up class that is just used to verify class TestReq05Notification(NotifyBase): @@ -1341,7 +1341,7 @@ def test_apprise_details(): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req05'] = TestReq05Notification + common.NOTIFY_SCHEMA_MAP['req05'] = TestReq05Notification # Create our Apprise instance a = Apprise() @@ -1707,14 +1707,16 @@ def test_apprise_details_plugin_verification(): if six.PY2: # inspect our object # getargspec() is deprecated in Python v3 - spec = inspect.getargspec(SCHEMA_MAP[protocols[0]].__init__) + spec = inspect.getargspec( + common.NOTIFY_SCHEMA_MAP[protocols[0]].__init__) function_args = \ (set(parse_list(spec.keywords)) - set(['kwargs'])) \ | (set(spec.args) - set(['self'])) | valid_kwargs else: # Python v3+ uses getfullargspec() - spec = inspect.getfullargspec(SCHEMA_MAP[protocols[0]].__init__) + spec = inspect.getfullargspec( + common.NOTIFY_SCHEMA_MAP[protocols[0]].__init__) function_args = \ (set(parse_list(spec.varkw)) - set(['kwargs'])) \ @@ -1729,7 +1731,8 @@ def test_apprise_details_plugin_verification(): raise AssertionError( '{}.__init__() expects a {}=None entry according to ' 'template configuration' - .format(SCHEMA_MAP[protocols[0]].__name__, arg)) + .format( + common.NOTIFY_SCHEMA_MAP[protocols[0]].__name__, arg)) # Iterate over all of the function arguments and make sure that # it maps back to a key @@ -1739,7 +1742,8 @@ def test_apprise_details_plugin_verification(): raise AssertionError( '{}.__init__({}) found but not defined in the ' 'template configuration' - .format(SCHEMA_MAP[protocols[0]].__name__, arg)) + .format( + common.NOTIFY_SCHEMA_MAP[protocols[0]].__name__, arg)) # Iterate over our map_to_aliases and make sure they were defined in # either the as a token or arg diff --git a/test/test_apprise_attachments.py b/test/test_apprise_attachments.py index 3e9bc838..1b26ce84 100644 --- a/test/test_apprise_attachments.py +++ b/test/test_apprise_attachments.py @@ -31,7 +31,7 @@ from os.path import dirname from apprise.AppriseAttachment import AppriseAttachment from apprise.AppriseAsset import AppriseAsset from apprise.attachment.AttachBase import AttachBase -from apprise.attachment import SCHEMA_MAP as ATTACH_SCHEMA_MAP +from apprise.common import ATTACHMENT_SCHEMA_MAP from apprise.attachment import __load_matrix from apprise.common import ContentLocation @@ -275,7 +275,7 @@ def test_apprise_attachment_instantiate(): raise TypeError() # Store our bad attachment type in our schema map - ATTACH_SCHEMA_MAP['bad'] = BadAttachType + ATTACHMENT_SCHEMA_MAP['bad'] = BadAttachType with pytest.raises(TypeError): AppriseAttachment.instantiate( diff --git a/test/test_apprise_config.py b/test/test_apprise_config.py index d5c3aecd..d947d525 100644 --- a/test/test_apprise_config.py +++ b/test/test_apprise_config.py @@ -37,8 +37,8 @@ from apprise import AppriseAsset from apprise.config.ConfigBase import ConfigBase from apprise.plugins.NotifyBase import NotifyBase -from apprise.config import SCHEMA_MAP as CONFIG_SCHEMA_MAP -from apprise.plugins import SCHEMA_MAP as NOTIFY_SCHEMA_MAP +from apprise.common import CONFIG_SCHEMA_MAP +from apprise.common import NOTIFY_SCHEMA_MAP from apprise.config import __load_matrix from apprise.config.ConfigFile import ConfigFile diff --git a/test/test_utils.py b/test/test_apprise_utils.py similarity index 69% rename from test/test_utils.py rename to test/test_apprise_utils.py index 1cb906ee..13e8f53e 100644 --- a/test/test_utils.py +++ b/test/test_apprise_utils.py @@ -26,7 +26,9 @@ from __future__ import print_function import re import os +import sys import six +from inspect import cleandoc try: # Python 2.7 from urllib import unquote @@ -36,11 +38,17 @@ except ImportError: from urllib.parse import unquote from apprise import utils +from apprise import common # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) +# Ensure we don't create .pyc files for these tests +sys.dont_write_bytecode = True +# Python v2.x support requires an environment variable +os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + def test_parse_qsd(): "utils: parse_qsd() testing """ @@ -64,7 +72,7 @@ def test_parse_qsd(): assert len(result['qsd:']) == 0 -def test_parse_url(): +def test_parse_url_general(): "utils: parse_url() testing """ result = utils.parse_url('http://hostname') @@ -493,6 +501,23 @@ def test_parse_url(): assert result['qsd+'] == {} assert result['qsd:'] == {} + result = utils.parse_url( + 'HTTPS://hostname/a/path/ending/with/slash/?key=value', + ) + assert result['schema'] == 'https' + assert result['host'] == 'hostname' + assert result['port'] is None + assert result['user'] is None + assert result['password'] is None + assert result['fullpath'] == '/a/path/ending/with/slash/' + assert result['path'] == '/a/path/ending/with/slash/' + assert result['query'] is None + assert result['url'] == 'https://hostname/a/path/ending/with/slash/' + assert result['qsd'] == {'key': 'value'} + assert result['qsd-'] == {} + assert result['qsd+'] == {} + assert result['qsd:'] == {} + # Handle garbage assert utils.parse_url(None) is None @@ -674,6 +699,408 @@ def test_parse_url(): assert result['qsd:'] == {} +def test_parse_url_simple(): + "utils: parse_url() testing """ + + result = utils.parse_url('http://hostname', simple=True) + assert len(result) == 3 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['url'] == 'http://hostname' + + result = utils.parse_url('http://hostname/', simple=True) + assert len(result) == 5 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'http://hostname/' + + # colon after hostname without port number is no good + assert utils.parse_url('http://hostname:', simple=True) is None + + # An invalid port + result = utils.parse_url( + 'http://hostname:invalid', verify_host=False, strict_port=True, + simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == 'invalid' + assert result['url'] == 'http://hostname:invalid' + + # However if we don't verify the host, it is okay + result = utils.parse_url( + 'http://hostname:', verify_host=False, simple=True) + assert len(result) == 3 + assert result['schema'] == 'http' + assert result['host'] == 'hostname:' + assert result['url'] == 'http://hostname:' + + # A port of Zero is not valid with strict port checking + assert utils.parse_url( + 'http://hostname:0', strict_port=True, simple=True) is None + + # Without strict port checking however, it is okay + result = utils.parse_url( + 'http://hostname:0', strict_port=False, simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['port'] == 0 + assert result['host'] == 'hostname' + assert result['url'] == 'http://hostname:0' + + # A negative port is not valid + assert utils.parse_url( + 'http://hostname:-92', strict_port=True, simple=True) is None + result = utils.parse_url( + 'http://hostname:-92', verify_host=False, strict_port=True, + simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == -92 + assert result['url'] == 'http://hostname:-92' + + # A port that is too large is not valid + assert utils.parse_url( + 'http://hostname:65536', strict_port=True, simple=True) is None + + # This is an accetable port (the maximum) + result = utils.parse_url( + 'http://hostname:65535', strict_port=True, simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == 65535 + assert result['url'] == 'http://hostname:65535' + + # This is an accetable port (the maximum) + result = utils.parse_url( + 'http://hostname:1', strict_port=True, simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == 1 + assert result['url'] == 'http://hostname:1' + + # A port that was identfied as a string is invalid + assert utils.parse_url( + 'http://hostname:invalid', strict_port=True, simple=True) is None + result = utils.parse_url( + 'http://hostname:invalid', verify_host=False, strict_port=True, + simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == 'invalid' + assert result['url'] == 'http://hostname:invalid' + + result = utils.parse_url( + 'http://hostname:invalid', verify_host=False, strict_port=False, + simple=True) + assert len(result) == 3 + assert result['schema'] == 'http' + assert result['host'] == 'hostname:invalid' + assert result['url'] == 'http://hostname:invalid' + + result = utils.parse_url( + 'http://hostname:invalid?key=value&-minuskey=mvalue', + verify_host=False, strict_port=False, simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname:invalid' + assert result['url'] == 'http://hostname:invalid' + assert isinstance(result['qsd'], dict) + assert len(result['qsd']) == 2 + assert unquote(result['qsd']['-minuskey']) == 'mvalue' + assert unquote(result['qsd']['key']) == 'value' + + # Handling of floats + assert utils.parse_url( + 'http://hostname:4.2', strict_port=True, simple=True) is None + result = utils.parse_url( + 'http://hostname:4.2', verify_host=False, strict_port=True, + simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == '4.2' + assert result['url'] == 'http://hostname:4.2' + + # A Port of zero is not acceptable for a regular hostname + assert utils.parse_url( + 'http://hostname:0', strict_port=True, simple=True) is None + + # No host verification (zero is an acceptable port when this is the case + result = utils.parse_url( + 'http://hostname:0', verify_host=False, simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == 0 + assert result['url'] == 'http://hostname:0' + + result = utils.parse_url( + 'http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080', + verify_host=False, simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == '[2001:db8:002a:3256:adfe:05c0:0003:0006]' + assert result['port'] == 8080 + assert result['url'] == \ + 'http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080' + + result = utils.parse_url( + 'http://hostname:0', verify_host=False, strict_port=True, simple=True) + assert len(result) == 4 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == 0 + assert result['url'] == 'http://hostname:0' + + result = utils.parse_url('http://hostname/?-KeY=Value', simple=True) + assert len(result) == 6 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'http://hostname/' + assert '-key' in result['qsd'] + assert unquote(result['qsd']['-key']) == 'Value' + + result = utils.parse_url('http://hostname/?+KeY=Value', simple=True) + assert len(result) == 6 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'http://hostname/' + assert '+key' in result['qsd'] + assert result['qsd']['+key'] == 'Value' + + result = utils.parse_url('http://hostname/?:kEy=vALUE', simple=True) + assert len(result) == 6 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'http://hostname/' + assert ':key' in result['qsd'] + assert result['qsd'][':key'] == 'vALUE' + + result = utils.parse_url( + 'http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:colon=y', + simple=True) + assert len(result) == 6 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'http://hostname/' + assert '+key' in result['qsd'] + assert '-key' in result['qsd'] + assert ':colon' in result['qsd'] + assert result['qsd'][':colon'] == 'y' + assert result['qsd']['key'] == 'Value C' + assert result['qsd']['+key'] == 'ValueA' + assert result['qsd']['-key'] == 'ValueB' + + result = utils.parse_url('http://hostname////', simple=True) + assert len(result) == 5 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'http://hostname/' + + result = utils.parse_url('http://hostname:40////', simple=True) + assert len(result) == 6 + assert result['schema'] == 'http' + assert result['host'] == 'hostname' + assert result['port'] == 40 + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'http://hostname:40/' + + result = utils.parse_url('HTTP://HoStNaMe:40/test.php', simple=True) + assert len(result) == 7 + assert result['schema'] == 'http' + assert result['host'] == 'HoStNaMe' + assert result['port'] == 40 + assert result['fullpath'] == '/test.php' + assert result['path'] == '/' + assert result['query'] == 'test.php' + assert result['url'] == 'http://HoStNaMe:40/test.php' + + result = utils.parse_url('HTTPS://user@hostname/test.py', simple=True) + assert len(result) == 7 + assert result['schema'] == 'https' + assert result['host'] == 'hostname' + assert result['user'] == 'user' + assert result['fullpath'] == '/test.py' + assert result['path'] == '/' + assert result['query'] == 'test.py' + assert result['url'] == 'https://user@hostname/test.py' + + result = utils.parse_url( + ' HTTPS://///user@@@hostname///test.py ', simple=True) + assert len(result) == 7 + assert result['schema'] == 'https' + assert result['host'] == 'hostname' + assert result['user'] == 'user' + assert result['fullpath'] == '/test.py' + assert result['path'] == '/' + assert result['query'] == 'test.py' + assert result['url'] == 'https://user@hostname/test.py' + + result = utils.parse_url( + 'HTTPS://user:password@otherHost/full///path/name/', + simple=True, + ) + assert len(result) == 7 + assert result['schema'] == 'https' + assert result['host'] == 'otherHost' + assert result['user'] == 'user' + assert result['password'] == 'password' + assert result['fullpath'] == '/full/path/name/' + assert result['path'] == '/full/path/name/' + assert result['url'] == 'https://user:password@otherHost/full/path/name/' + + # Handle garbage + assert utils.parse_url(None) is None + + result = utils.parse_url( + 'mailto://user:password@otherHost/lead2gold@gmail.com' + + '?from=test@test.com&name=Chris%20Caron&format=text', + simple=True, + ) + assert len(result) == 9 + assert result['schema'] == 'mailto' + assert result['host'] == 'otherHost' + assert result['user'] == 'user' + assert result['password'] == 'password' + assert unquote(result['fullpath']) == '/lead2gold@gmail.com' + assert result['path'] == '/' + assert unquote(result['query']) == 'lead2gold@gmail.com' + assert unquote(result['url']) == \ + 'mailto://user:password@otherHost/lead2gold@gmail.com' + assert len(result['qsd']) == 3 + assert 'name' in result['qsd'] + assert unquote(result['qsd']['name']) == 'Chris Caron' + assert 'from' in result['qsd'] + assert unquote(result['qsd']['from']) == 'test@test.com' + assert 'format' in result['qsd'] + assert unquote(result['qsd']['format']) == 'text' + + # Test Passwords with question marks ?; not supported + result = utils.parse_url( + 'http://user:pass.with.?question@host', + simple=True + ) + assert result is None + + # just hostnames + result = utils.parse_url( + 'nuxref.com', simple=True, + ) + assert len(result) == 3 + assert result['schema'] == 'http' + assert result['host'] == 'nuxref.com' + assert result['url'] == 'http://nuxref.com' + + # just host and path + result = utils.parse_url('invalid/host', simple=True) + assert len(result) == 6 + assert result['schema'] == 'http' + assert result['host'] == 'invalid' + assert result['fullpath'] == '/host' + assert result['path'] == '/' + assert result['query'] == 'host' + assert result['url'] == 'http://invalid/host' + + # just all out invalid + assert utils.parse_url('?', simple=True) is None + assert utils.parse_url('/', simple=True) is None + + # Test some illegal strings + result = utils.parse_url(object, verify_host=False, simple=True) + assert result is None + result = utils.parse_url(None, verify_host=False, simple=True) + assert result is None + + # Just a schema; invalid host + result = utils.parse_url('test://', simple=True) + assert result is None + + # Do it again without host validation + result = utils.parse_url('test://', verify_host=False, simple=True) + assert len(result) == 2 + assert result['schema'] == 'test' + assert result['url'] == 'test://' + + result = utils.parse_url('testhostname', simple=True) + assert len(result) == 3 + assert result['schema'] == 'http' + assert result['host'] == 'testhostname' + # The default_schema kicks in here + assert result['url'] == 'http://testhostname' + + result = utils.parse_url( + 'example.com', default_schema='unknown', simple=True) + assert len(result) == 3 + assert result['schema'] == 'unknown' + assert result['host'] == 'example.com' + # The default_schema kicks in here + assert result['url'] == 'unknown://example.com' + + # An empty string without a hostame is still valid if verify_host is set + result = utils.parse_url('', verify_host=False, simple=True) + assert len(result) == 2 + assert result['schema'] == 'http' + # The default_schema kicks in here + assert result['url'] == 'http://' + + # A messed up URL + result = utils.parse_url('test://:@/', verify_host=False, simple=True) + assert len(result) == 6 + assert result['schema'] == 'test' + assert result['user'] == '' + assert result['password'] == '' + assert result['fullpath'] == '/' + assert result['path'] == '/' + assert result['url'] == 'test://:@/' + + result = utils.parse_url( + 'crazy://:@//_/@^&/jack.json', verify_host=False, simple=True) + assert len(result) == 7 + assert result['schema'] == 'crazy' + assert result['user'] == '' + assert result['password'] == '' + assert unquote(result['fullpath']) == '/_/@^&/jack.json' + assert unquote(result['path']) == '/_/@^&/' + assert result['query'] == 'jack.json' + assert unquote(result['url']) == 'crazy://:@/_/@^&/jack.json' + + +def test_url_assembly(): + """ + "utils: url_assembly() testing """ + + url = 'schema://user:password@hostname:port/path/?key=value' + assert utils.url_assembly(**utils.parse_url(url, verify_host=False)) == url + + # Same URL without trailing slash after path + url = 'schema://user:password@hostname:port/path?key=value' + assert utils.url_assembly(**utils.parse_url(url, verify_host=False)) == url + + url = 'schema://user@hostname:port/path?key=value' + assert utils.url_assembly(**utils.parse_url(url, verify_host=False)) == url + + url = 'schema://hostname:10/a/file.php' + assert utils.url_assembly(**utils.parse_url(url, verify_host=False)) == url + + def test_parse_bool(): "utils: parse_bool() testing """ @@ -1456,6 +1883,48 @@ def test_parse_urls(): assert len(results) == 0 +def test_dict_full_update(): + """utils: dict_full_update() testing """ + dict_1 = { + 'a': 1, + 'b': 2, + 'c': 3, + 'd': { + 'z': 27, + 'y': 26, + 'x': 25, + } + } + + dict_2 = { + 'd': { + 'x': 'updated', + 'w': 24, + }, + 'c': 'updated', + 'e': 5, + } + utils.dict_full_update(dict_1, dict_2) + # Dictionary 2 is untouched + assert len(dict_2) == 3 + assert dict_2['c'] == 'updated' + assert dict_2['d']['w'] == 24 + assert dict_2['d']['x'] == 'updated' + assert dict_2['e'] == 5 + + # Dictionary 3 however has entries from Dict 2 applied + # without disrupting entries that were not matched. + assert len(dict_1) == 5 + assert dict_1['a'] == 1 + assert dict_1['b'] == 2 + assert dict_1['c'] == 'updated' + assert dict_1['d']['w'] == 24 + assert dict_1['d']['x'] == 'updated' + assert dict_1['d']['y'] == 26 + assert dict_1['d']['z'] == 27 + assert dict_1['e'] == 5 + + def test_parse_list(): """utils: parse_list() testing """ @@ -1499,6 +1968,338 @@ def test_parse_list(): ]) +def test_module_detection(tmpdir): + """utils: test_module_detection() testing + """ + + # Clear our working variables so they don't obstruct with the tests here + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + # Test case where we load invalid data + utils.module_detection(None) + + # Invalid data does not load anything + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 0 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Prepare ourselves a file to work with + notify_hook_a_base = tmpdir.mkdir('a') + notify_hook_a = notify_hook_a_base.join('hook.py') + notify_hook_a.write(cleandoc(""" + from apprise.decorators import notify + + @notify(on="clihook") + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + """)) + + # Not previously loaded + assert 'clihook' not in common.NOTIFY_SCHEMA_MAP + + # load entry by string + utils.module_detection(str(notify_hook_a)) + + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # Now loaded + assert 'clihook' in common.NOTIFY_SCHEMA_MAP + + # load entry by array + utils.module_detection([str(notify_hook_a)]) + + # No changes to our path + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # Reset our variables for the next test + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + del common.NOTIFY_SCHEMA_MAP['clihook'] + + # Hidden files are ignored + notify_hook_b_base = tmpdir.mkdir('b') + notify_hook_b = notify_hook_b_base.join('.hook.py') + notify_hook_b.write(cleandoc(""" + from apprise.decorators import notify + + # this is in a hidden file so it will not load + @notify(on="hidden") + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + """)) + + assert 'hidden' not in common.NOTIFY_SCHEMA_MAP + + utils.module_detection([str(notify_hook_b)]) + + # Verify that it did not load + assert 'hidden' not in common.NOTIFY_SCHEMA_MAP + + # Path was scanned; nothing loaded + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Reset our variables for the next test + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + # modules with no hooks found are ignored + notify_hook_c_base = tmpdir.mkdir('c') + notify_hook_c = notify_hook_c_base.join('empty.py') + notify_hook_c.write("") + + utils.module_detection([str(notify_hook_c)]) + + # File was found, no custom modules + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # A new path scanned + utils.module_detection([str(notify_hook_c_base)]) + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + def create_hook(tdir, cache=True, on="valid1"): + """ + Just a temporary hook creation tool for writing a working notify hook + """ + tdir.write(cleandoc(""" + from apprise.decorators import notify + + # this is a good hook but burried in hidden directory which won't + # be accessed unless the file is pointed to via absolute path + @notify(on="{}") + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + """.format(on))) + + utils.module_detection([str(tdir)], cache=cache) + + create_hook(notify_hook_c, on='valid1') + assert 'valid1' not in common.NOTIFY_SCHEMA_MAP + + # Even if we correct our empty file; the fact the directory has been + # scanned and failed to load (same with file), it won't be loaded + # a second time. This is intentional since module_detection() get's + # called for every AppriseAsset() object creation. This prevents us + # from reloading conent over and over again wasting resources + assert 'valid1' not in common.NOTIFY_SCHEMA_MAP + utils.module_detection([str(notify_hook_c)]) + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Even by absolute path... + utils.module_detection([str(notify_hook_c)]) + assert 'valid1' not in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # However we can bypass the cache if we really want to + utils.module_detection([str(notify_hook_c_base)], cache=False) + assert 'valid1' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # Bypassing it twice causes the module to load twice (not very efficient) + # However we can bypass the cache if we really want to + utils.module_detection([str(notify_hook_c_base)], cache=False) + assert 'valid1' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # If you update the module (corrupting it in the process and reload) + notify_hook_c.write(cleandoc(""" + raise ValueError + """)) + + # Force no cache to cause the file to be replaced + utils.module_detection([str(notify_hook_c_base)], cache=False) + + # Our valid entry is no longer loaded + assert 'valid1' not in common.NOTIFY_SCHEMA_MAP + + # No change to scanned paths + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + # The previously loaded module is now gone + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Reload our valid1 entry + create_hook(notify_hook_c, on='valid1', cache=False) + assert 'valid1' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # Prepare an empty file + notify_hook_c.write("") + utils.module_detection([str(notify_hook_c_base)], cache=False) + + # Our valid entry is no longer loaded + assert 'valid1' not in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Now reload our module again (this time rather then an exception, the + # module is read back and swaps `valid1` for `valid2` + create_hook(notify_hook_c, on='valid1', cache=False) + assert 'valid1' in common.NOTIFY_SCHEMA_MAP + assert 'valid2' not in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + create_hook(notify_hook_c, on='valid2', cache=False) + assert 'valid1' not in common.NOTIFY_SCHEMA_MAP + assert 'valid2' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # Reset our variables for the next test + create_hook(notify_hook_c, on='valid1', cache=False) + del common.NOTIFY_SCHEMA_MAP['valid1'] + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + notify_hook_d = notify_hook_c_base.join('.ignore.py') + notify_hook_d.write("") + notify_hook_e_base = notify_hook_c_base.mkdir('.ignore') + notify_hook_e = notify_hook_e_base.join('__init__.py') + notify_hook_e.write(cleandoc(""" + from apprise.decorators import notify + + # this is a good hook but burried in hidden directory which won't + # be accessed unless the file is pointed to via absolute path + @notify(on="valid2") + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + """)) + + # Try to load our base directory again; this time we search by the + # directory; the only edge case we're testing here is it will not + # even look at the .ignore.py file found since it is invalid + utils.module_detection([str(notify_hook_c_base)]) + assert 'valid1' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # Reset our variables for the next test + del common.NOTIFY_SCHEMA_MAP['valid1'] + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + # Try to load our base directory again + utils.module_detection([str(notify_hook_c)]) + assert 'valid1' in common.NOTIFY_SCHEMA_MAP + + # Hidden directories are not scanned + assert 'valid2' not in common.NOTIFY_SCHEMA_MAP + + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert str(notify_hook_c) in utils.PATHS_PREVIOUSLY_SCANNED + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # However a direct reference to the hidden directory is okay + utils.module_detection([str(notify_hook_e_base)]) + + # We loaded our module + assert 'valid2' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 3 + assert str(notify_hook_c) in utils.PATHS_PREVIOUSLY_SCANNED + assert str(notify_hook_e) in utils.PATHS_PREVIOUSLY_SCANNED + assert str(notify_hook_e_base) in utils.PATHS_PREVIOUSLY_SCANNED + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 2 + + # Reset our variables for the next test + del common.NOTIFY_SCHEMA_MAP['valid1'] + del common.NOTIFY_SCHEMA_MAP['valid2'] + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + # Load our file directly + assert 'valid2' not in common.NOTIFY_SCHEMA_MAP + utils.module_detection([str(notify_hook_e)]) + + # Now we have it loaded as expected + assert 'valid2' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert str(notify_hook_e) in utils.PATHS_PREVIOUSLY_SCANNED + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # however if we try to load the base directory where the __init__.py + # was already loaded from, it will not change anything + utils.module_detection([str(notify_hook_e_base)]) + assert 'valid2' in common.NOTIFY_SCHEMA_MAP + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 + assert str(notify_hook_e) in utils.PATHS_PREVIOUSLY_SCANNED + assert str(notify_hook_e_base) in utils.PATHS_PREVIOUSLY_SCANNED + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # Tidy up for the next test + del common.NOTIFY_SCHEMA_MAP['valid2'] + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + assert 'valid1' not in common.NOTIFY_SCHEMA_MAP + assert 'valid2' not in common.NOTIFY_SCHEMA_MAP + assert 'valid3' not in common.NOTIFY_SCHEMA_MAP + notify_hook_f_base = tmpdir.mkdir('f') + notify_hook_f = notify_hook_f_base.join('invalid.py') + notify_hook_f.write(cleandoc(""" + from apprise.decorators import notify + + # A very invalid hook type... on should not be None + @notify(on=None) + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + + # An invalid name + @notify(on='valid1', name=None) + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + + # Another invalid name (so it's ignored) + @notify(on='valid2', name=object) + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + + # Simply put... the name has to be a string to be referenced + # however this will still be loaded + @notify(on='valid3', name=4) + def mywrapper(body, title, notify_type, *args, **kwargs): + pass + + """)) + + utils.module_detection([str(notify_hook_f)]) + + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 + assert 'valid1' in common.NOTIFY_SCHEMA_MAP + assert 'valid2' in common.NOTIFY_SCHEMA_MAP + assert 'valid3' in common.NOTIFY_SCHEMA_MAP + + # Reset our variables for the next test + del common.NOTIFY_SCHEMA_MAP['valid1'] + del common.NOTIFY_SCHEMA_MAP['valid2'] + del common.NOTIFY_SCHEMA_MAP['valid3'] + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + # Now test the handling of just bad data entirely + notify_hook_g_base = tmpdir.mkdir('g') + notify_hook_g = notify_hook_g_base.join('binary.py') + with open(str(notify_hook_g), 'wb') as fout: + fout.write(os.urandom(512)) + + utils.module_detection([str(notify_hook_g)]) + assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 + assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Reset our variables before we leave + utils.PATHS_PREVIOUSLY_SCANNED.clear() + common.NOTIFY_CUSTOM_MODULE_MAP.clear() + + def test_exclusive_match(): """utils: is_exclusive_match() testing """ @@ -1842,11 +2643,11 @@ def test_cwe312_url(): assert utils.cwe312_url( 'gitter://b5637831f563aa846bb5b2c27d8fe8f633b8f026' '/apprise/?pass=abc123') == \ - 'gitter://b...6/apprise?pass=a...3' + 'gitter://b...6/apprise/?pass=a...3' assert utils.cwe312_url( 'slack://mybot@xoxb-43598234231-3248932482278-BZK5Wj15B9mPh1RkShJoCZ44' '/lead2gold@gmail.com') == 'slack://mybot@x...4/l...m' assert utils.cwe312_url( 'slack://test@B4QP3WWB4/J3QWT41JM/XIl2ffpqXkzkwMXrJdevi7W3/' - '#random') == 'slack://test@B...4/J...M/X...3' + '#random') == 'slack://test@B...4/J...M/X...3/' diff --git a/test/test_asyncio.py b/test/test_asyncio.py index 088c8fb4..7d542332 100644 --- a/test/test_asyncio.py +++ b/test/test_asyncio.py @@ -31,7 +31,7 @@ from apprise import Apprise from apprise import NotifyBase from apprise import NotifyFormat -from apprise.plugins import SCHEMA_MAP +from apprise.common import NOTIFY_SCHEMA_MAP if not six.PY2: import apprise.py3compat.asyncio as py3aio @@ -67,7 +67,7 @@ def test_apprise_asyncio_runtime_error(): return NotifyBase.parse_url(url, verify_host=False) # Store our good notification in our schema map - SCHEMA_MAP['good'] = GoodNotification + NOTIFY_SCHEMA_MAP['good'] = GoodNotification # Create ourselves an Apprise object a = Apprise() @@ -138,7 +138,7 @@ def test_apprise_works_in_async_loop(): return NotifyBase.parse_url(url, verify_host=False) # Store our good notification in our schema map - SCHEMA_MAP['good'] = GoodNotification + NOTIFY_SCHEMA_MAP['good'] = GoodNotification # Create ourselves an Apprise object a = Apprise() diff --git a/test/test_attach_http.py b/test/test_attach_http.py index 89e19ef9..bd029495 100644 --- a/test/test_attach_http.py +++ b/test/test_attach_http.py @@ -34,7 +34,7 @@ from os.path import getsize from apprise.attachment.AttachHTTP import AttachHTTP from apprise import AppriseAttachment from apprise.plugins.NotifyBase import NotifyBase -from apprise.plugins import SCHEMA_MAP +from apprise.common import NOTIFY_SCHEMA_MAP from apprise.common import ContentLocation # Disable logging for a cleaner testing output @@ -128,7 +128,7 @@ def test_attach_http(mock_get): return '' # Store our good notification in our schema map - SCHEMA_MAP['good'] = GoodNotification + NOTIFY_SCHEMA_MAP['good'] = GoodNotification # Temporary path path = join(TEST_VAR_DIR, 'apprise-test.gif') diff --git a/test/test_cli.py b/test/test_cli.py index 917fa5a6..7a453185 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -27,12 +27,15 @@ import re import mock import requests import json +from inspect import cleandoc from os.path import dirname from os.path import join from apprise import cli from apprise import NotifyBase +from apprise.common import NOTIFY_CUSTOM_MODULE_MAP +from apprise.utils import PATHS_PREVIOUSLY_SCANNED from click.testing import CliRunner -from apprise.plugins import SCHEMA_MAP +from apprise.common import NOTIFY_SCHEMA_MAP from apprise.utils import environ from apprise.plugins import __load_matrix from apprise.plugins import __reset_matrix @@ -85,8 +88,8 @@ def test_apprise_cli_nux_env(tmpdir): return 'bad://' # Set up our notification types - SCHEMA_MAP['good'] = GoodNotification - SCHEMA_MAP['bad'] = BadNotification + NOTIFY_SCHEMA_MAP['good'] = GoodNotification + NOTIFY_SCHEMA_MAP['bad'] = BadNotification runner = CliRunner() result = runner.invoke(cli.main) @@ -476,10 +479,10 @@ def test_apprise_cli_nux_env(tmpdir): ]) assert result.exit_code == 0 - with mock.patch('apprise.cli.DEFAULT_SEARCH_PATHS', []): + with mock.patch('apprise.cli.DEFAULT_CONFIG_PATHS', []): with environ(APPRISE_URLS=" "): # An empty string is not valid and therefore not loaded so the - # below fails. We override the DEFAULT_SEARCH_PATHS because we + # below fails. We override the DEFAULT_CONFIG_PATHS because we # don't want to detect ones loaded on the machine running the unit # tests result = runner.invoke(cli.main, [ @@ -519,11 +522,11 @@ def test_apprise_cli_nux_env(tmpdir): ]) assert result.exit_code == 0 - with mock.patch('apprise.cli.DEFAULT_SEARCH_PATHS', []): + with mock.patch('apprise.cli.DEFAULT_CONFIG_PATHS', []): with environ(APPRISE_CONFIG=" "): # We will fail to send the notification as no path was # specified. - # We override the DEFAULT_SEARCH_PATHS because we don't + # We override the DEFAULT_CONFIG_PATHS because we don't # want to detect ones loaded on the machine running the unit tests result = runner.invoke(cli.main, [ '-b', 'my message', @@ -598,7 +601,7 @@ def test_apprise_cli_nux_env(tmpdir): def test_apprise_cli_details(tmpdir): """ - API: Apprise() Disabled Plugin States + CLI: --details (-l) """ @@ -644,7 +647,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req01'] = TestReq01Notification + NOTIFY_SCHEMA_MAP['req01'] = TestReq01Notification # This is a made up class that is just used to verify class TestReq02Notification(NotifyBase): @@ -674,7 +677,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req02'] = TestReq02Notification + NOTIFY_SCHEMA_MAP['req02'] = TestReq02Notification # This is a made up class that is just used to verify class TestReq03Notification(NotifyBase): @@ -700,7 +703,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req03'] = TestReq03Notification + NOTIFY_SCHEMA_MAP['req03'] = TestReq03Notification # This is a made up class that is just used to verify class TestReq04Notification(NotifyBase): @@ -720,7 +723,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req04'] = TestReq04Notification + NOTIFY_SCHEMA_MAP['req04'] = TestReq04Notification # This is a made up class that is just used to verify class TestReq05Notification(NotifyBase): @@ -741,7 +744,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['req05'] = TestReq04Notification + NOTIFY_SCHEMA_MAP['req05'] = TestReq04Notification class TestDisabled01Notification(NotifyBase): """ @@ -763,7 +766,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['na01'] = TestDisabled01Notification + NOTIFY_SCHEMA_MAP['na01'] = TestDisabled01Notification class TestDisabled02Notification(NotifyBase): """ @@ -788,7 +791,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['na02'] = TestDisabled02Notification + NOTIFY_SCHEMA_MAP['na02'] = TestDisabled02Notification # We'll add a good notification to our list class TesEnabled01Notification(NotifyBase): @@ -808,7 +811,7 @@ def test_apprise_cli_details(tmpdir): # Pretend everything is okay (so we don't break other tests) return True - SCHEMA_MAP['good'] = TesEnabled01Notification + NOTIFY_SCHEMA_MAP['good'] = TesEnabled01Notification # Verify that we can pass through all of our different details result = runner.invoke(cli.main, [ @@ -826,6 +829,371 @@ def test_apprise_cli_details(tmpdir): __load_matrix() +@mock.patch('requests.post') +def test_apprise_cli_plugin_loading(mock_post, tmpdir): + """ + CLI: --plugin-path (-P) + + """ + # Prepare Mock + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + + runner = CliRunner() + + # Clear our working variables so they don't obstruct the next test + # This simulates an actual call from the CLI. Unfortunately through + # testing were occupying the same memory space so our singleton's + # have already been populated + PATHS_PREVIOUSLY_SCANNED.clear() + NOTIFY_CUSTOM_MODULE_MAP.clear() + + # Test a path that has no files to load in it + result = runner.invoke(cli.main, [ + '--plugin-path', join(str(tmpdir), 'invalid_path'), + '-b', 'test\nbody', + 'json://localhost', + ]) + # The path is silently loaded but fails... it's okay because the + # notification we're choosing to notify does exist + assert result.exit_code == 0 + + # Directories that don't exist passed in by the CLI aren't even scanned + assert len(PATHS_PREVIOUSLY_SCANNED) == 0 + assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Test our current existing path that has no entries in it + result = runner.invoke(cli.main, [ + '--plugin-path', str(tmpdir.mkdir('empty')), + '-b', 'test\nbody', + 'json://localhost', + ]) + # The path is silently loaded but fails... it's okay because the + # notification we're choosing to notify does exist + assert result.exit_code == 0 + assert len(PATHS_PREVIOUSLY_SCANNED) == 1 + assert join(str(tmpdir), 'empty') in PATHS_PREVIOUSLY_SCANNED + + # However there was nothing to load + assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Clear our working variables so they don't obstruct the next test + # This simulates an actual call from the CLI. Unfortunately through + # testing were occupying the same memory space so our singleton's + # have already been populated + PATHS_PREVIOUSLY_SCANNED.clear() + NOTIFY_CUSTOM_MODULE_MAP.clear() + + # Prepare ourselves a file to work with + notify_hook_a_base = tmpdir.mkdir('random') + notify_hook_a = notify_hook_a_base.join('myhook01.py') + notify_hook_a.write(cleandoc(""" + raise ImportError + """)) + + result = runner.invoke(cli.main, [ + '--plugin-path', str(notify_hook_a), + '-b', 'test\nbody', + # A custom hook: + 'clihook://', + ]) + # It doesn't exist so it will fail + # meanwhile we would have failed to load the myhook path + assert result.exit_code == 1 + + # The path is silently loaded but fails... it's okay because the + # notification we're choosing to notify does exist + assert len(PATHS_PREVIOUSLY_SCANNED) == 1 + assert str(notify_hook_a) in PATHS_PREVIOUSLY_SCANNED + # However there was nothing to load + assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Prepare ourselves a file to work with + notify_hook_aa = notify_hook_a_base.join('myhook02.py') + notify_hook_aa.write(cleandoc(""" + garbage entry + """)) + + result = runner.invoke(cli.main, [ + '--plugin-path', str(notify_hook_aa), + '-b', 'test\nbody', + # A custom hook: + 'clihook://', + ]) + # It doesn't exist so it will fail + # meanwhile we would have failed to load the myhook path + assert result.exit_code == 1 + + # The path is silently loaded but fails... + # as a result the path stacks with the last + assert len(PATHS_PREVIOUSLY_SCANNED) == 2 + assert str(notify_hook_a) in PATHS_PREVIOUSLY_SCANNED + assert str(notify_hook_aa) in PATHS_PREVIOUSLY_SCANNED + # However there was nothing to load + assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 + + # Clear our working variables so they don't obstruct the next test + # This simulates an actual call from the CLI. Unfortunately through + # testing were occupying the same memory space so our singleton's + # have already been populated + PATHS_PREVIOUSLY_SCANNED.clear() + NOTIFY_CUSTOM_MODULE_MAP.clear() + + # Prepare ourselves a file to work with + notify_hook_b = tmpdir.mkdir('goodmodule').join('__init__.py') + notify_hook_b.write(cleandoc(""" + from apprise.decorators import notify + + # We want to trigger on anyone who configures a call to clihook:// + @notify(on="clihook") + def mywrapper(body, title, notify_type, *args, **kwargs): + # A simple test - print to screen + print("{}: {} - {}".format(notify_type, title, body)) + + # No return (so a return of None) get's translated to True + """)) + + result = runner.invoke(cli.main, [ + '--plugin-path', str(tmpdir), + '-b', 'test body', + # A custom hook: + 'clihook://', + ]) + + # We can detect the goodmodule (which has an __init__.py in it) + # so we'll load okay + assert result.exit_code == 0 + + # Let's see how things got loaded: + assert len(PATHS_PREVIOUSLY_SCANNED) == 2 + assert str(tmpdir) in PATHS_PREVIOUSLY_SCANNED + # absolute path to detected module is also added + assert join(str(tmpdir), 'goodmodule', '__init__.py') \ + in PATHS_PREVIOUSLY_SCANNED + + # We also loaded our clihook properly + assert len(NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # We can find our new hook loaded in our NOTIFY_SCHEMA_MAP now... + assert 'clihook' in NOTIFY_SCHEMA_MAP + + # Store our key after parsing it as a list (this makes this test backwards + # compatible with Python 2.x + key = [k for k in NOTIFY_CUSTOM_MODULE_MAP.keys()][0] + + assert len(NOTIFY_CUSTOM_MODULE_MAP[key]['notify']) == 1 + assert 'clihook' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] + + # Our function name + assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['fn_name'] \ + == 'mywrapper' + # What we parsed from the `on` keyword in the @notify decorator + assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['url'] \ + == 'clihook://' + # our default name Assignment. This can be-overridden on the @notify + # decorator by just adding a name= to the parameter list + assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['name'] \ + == 'Custom - clihook' + + # Our Base Notification object when initialized: + assert isinstance( + NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['plugin'](), + NotifyBase) + + # This is how it ties together in the backend + assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['plugin'] == \ + NOTIFY_SCHEMA_MAP['clihook'] + + # Clear our working variables so they don't obstruct the next test + # This simulates an actual call from the CLI. Unfortunately through + # testing were occupying the same memory space so our singleton's + # have already been populated + PATHS_PREVIOUSLY_SCANNED.clear() + NOTIFY_CUSTOM_MODULE_MAP.clear() + del NOTIFY_SCHEMA_MAP['clihook'] + + result = runner.invoke(cli.main, [ + '--plugin-path', str(notify_hook_b), + '-b', 'test body', + # A custom hook: + 'clihook://', + ]) + + # Absolute path to __init__.py is okay + assert result.exit_code == 0 + + # we can verify that it prepares our message + assert result.stdout.strip() == 'info: - test body' + + # Clear our working variables so they don't obstruct the next test + # This simulates an actual call from the CLI. Unfortunately through + # testing were occupying the same memory space so our singleton's + # have already been populated + PATHS_PREVIOUSLY_SCANNED.clear() + NOTIFY_CUSTOM_MODULE_MAP.clear() + del NOTIFY_SCHEMA_MAP['clihook'] + + result = runner.invoke(cli.main, [ + '--plugin-path', dirname(str(notify_hook_b)), + '-b', 'test body', + # A custom hook: + 'clihook://', + ]) + + # Now we succeed to load our module when pointed to it only because + # an __init__.py is found on the inside of it + assert result.exit_code == 0 + + # we can verify that it prepares our message + assert result.stdout.strip() == 'info: - test body' + + # Test double paths that are the same; this ensures we only + # load the plugin once + result = runner.invoke(cli.main, [ + '--plugin-path', dirname(str(notify_hook_b)), + '--plugin-path', str(notify_hook_b), + '--details', + ]) + + # Now we succeed to load our module when pointed to it only because + # an __init__.py is found on the inside of it + assert result.exit_code == 0 + + # Clear our working variables so they don't obstruct the next test + # This simulates an actual call from the CLI. Unfortunately through + # testing were occupying the same memory space so our singleton's + # have already been populated + PATHS_PREVIOUSLY_SCANNED.clear() + NOTIFY_CUSTOM_MODULE_MAP.clear() + del NOTIFY_SCHEMA_MAP['clihook'] + + # Prepare ourselves a file to work with + notify_hook_b = tmpdir.mkdir('complex').join('complex.py') + notify_hook_b.write(cleandoc(""" + from apprise.decorators import notify + + # We can't over-ride an element that already exists + # in this case json:// + @notify(on="json") + def mywrapper_01(body, title, notify_type, *args, **kwargs): + # Return True (same as None) + return True + + @notify(on="willfail", name="always failing...") + def mywrapper_02(body, title, notify_type, *args, **kwargs): + # Simply fail + return False + + @notify(on="clihook1", name="the original clihook entry") + def mywrapper_03(body, title, notify_type, *args, **kwargs): + # Return True + return True + + # This is a duplicate o the entry above, so it can not be + # loaded... + @notify(on="clihook1", name="a duplicate of the clihook entry") + def mywrapper_04(body, title, notify_type, *args, **kwargs): + # Return True + return True + + # This is where things get realy cool... we can not only + # define the schema we want to over-ride, but we can define + # some default values to pass into our wrapper function to + # act as a base before whatever was actually passed in is + # applied ontop.... think of it like templating information + @notify(on="clihook2://localhost") + def mywrapper_05(body, title, notify_type, *args, **kwargs): + # Return True + return True + + + # This can't load because of the defined schema/on definition + @notify(on="", name="an invalid schema was specified") + def mywrapper_06(body, title, notify_type, *args, **kwargs): + return True + """)) + + result = runner.invoke(cli.main, [ + '--plugin-path', join(str(tmpdir), 'complex'), + '-b', 'test body', + # A custom hook that does not exist + 'clihook://', + ]) + + # Since clihook:// isn't in our complex listing, this will fail + assert result.exit_code == 1 + + # Let's see how things got loaded + assert len(PATHS_PREVIOUSLY_SCANNED) == 2 + # Our path we specified on the CLI... + assert join(str(tmpdir), 'complex') in PATHS_PREVIOUSLY_SCANNED + + # absolute path to detected module is also added + assert join(str(tmpdir), 'complex', 'complex.py') \ + in PATHS_PREVIOUSLY_SCANNED + + # We loaded our one module successfuly + assert len(NOTIFY_CUSTOM_MODULE_MAP) == 1 + + # We can find our new hook loaded in our SCHEMA_MAP now... + assert 'willfail' in NOTIFY_SCHEMA_MAP + assert 'clihook1' in NOTIFY_SCHEMA_MAP + assert 'clihook2' in NOTIFY_SCHEMA_MAP + + # Store our key after parsing it as a list (this makes this test backwards + # compatible with Python 2.x + key = [k for k in NOTIFY_CUSTOM_MODULE_MAP.keys()][0] + + assert len(NOTIFY_CUSTOM_MODULE_MAP[key]['notify']) == 3 + assert 'willfail' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] + assert 'clihook1' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] + # We only load 1 instance of the clihook2, the second will fail + assert 'clihook2' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] + # We can never load previously created notifications + assert 'json' not in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] + + result = runner.invoke(cli.main, [ + '--plugin-path', join(str(tmpdir), 'complex'), + '-b', 'test body', + # A custom notification set up for failure + 'willfail://', + ]) + # Note that the failure of the decorator carries all the way back + # to the CLI + assert result.exit_code == 1 + + result = runner.invoke(cli.main, [ + '--plugin-path', join(str(tmpdir), 'complex'), + '-b', 'test body', + # our clihook that returns true + 'clihook1://', + # our other loaded clihook + 'clihook2://', + ]) + # Note that the failure of the decorator carries all the way back + # to the CLI + assert result.exit_code == 0 + + result = runner.invoke(cli.main, [ + '--plugin-path', join(str(tmpdir), 'complex'), + # Print our custom details to the screen + '--details', + ]) + assert 'willfail' in result.stdout + assert 'always failing...' in result.stdout + + assert 'clihook1' in result.stdout + assert 'the original clihook entry' in result.stdout + assert 'a duplicate of the clihook entry' not in result.stdout + + assert 'clihook2' in result.stdout + assert 'Custom - clihook2' in result.stdout + + # Note that the failure of the decorator carries all the way back + # to the CLI + assert result.exit_code == 0 + + @mock.patch('platform.system') def test_apprise_cli_windows_env(mock_system): """ diff --git a/test/test_config_http.py b/test/test_config_http.py index 2dfd01ee..6a564ab0 100644 --- a/test/test_config_http.py +++ b/test/test_config_http.py @@ -31,7 +31,7 @@ import requests from apprise.common import ConfigFormat from apprise.config.ConfigHTTP import ConfigHTTP from apprise.plugins.NotifyBase import NotifyBase -from apprise.plugins import SCHEMA_MAP +from apprise.common import NOTIFY_SCHEMA_MAP # Disable logging for a cleaner testing output import logging @@ -74,7 +74,7 @@ def test_config_http(mock_post): return '' # Store our good notification in our schema map - SCHEMA_MAP['good'] = GoodNotification + NOTIFY_SCHEMA_MAP['good'] = GoodNotification # Our default content default_content = """taga,tagb=good://server01""" diff --git a/test/test_decorator_notify.py b/test/test_decorator_notify.py new file mode 100644 index 00000000..e8536f3a --- /dev/null +++ b/test/test_decorator_notify.py @@ -0,0 +1,310 @@ +# -*- 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 os.path import dirname +from os.path import join +from apprise.decorators import notify +from apprise import Apprise +from apprise import AppriseAsset +from apprise import AppriseAttachment +from apprise import common + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +TEST_VAR_DIR = join(dirname(__file__), 'var') + + +def test_notify_simple_decoration(): + """decorators: Test simple @notify + """ + + # Verify our schema we're about to declare doesn't already exist + # in our schema map: + assert 'utiltest' not in common.NOTIFY_SCHEMA_MAP + + verify_obj = {} + + # Define a function here on the spot + @notify(on="utiltest", name="Apprise @notify Decorator Testing") + def my_inline_notify_wrapper( + body, title, notify_type, attach, *args, **kwargs): + + # Populate our object we can use to validate + verify_obj.update({ + 'body': body, + 'title': title, + 'notify_type': notify_type, + 'attach': attach, + 'args': args, + 'kwargs': kwargs, + }) + + # Now after our hook being inline... it's been loaded + assert 'utiltest' in common.NOTIFY_SCHEMA_MAP + + # Create ourselves an apprise object + aobj = Apprise() + + assert aobj.add("utiltest://") is True + + assert len(verify_obj) == 0 + + assert aobj.notify( + "Hello World", title="My Title", + # add some attachments too + attach=( + join(TEST_VAR_DIR, 'apprise-test.gif'), + join(TEST_VAR_DIR, 'apprise-test.png'), + ) + ) is True + + # Our content was populated after the notify() call + assert len(verify_obj) > 0 + assert verify_obj['body'] == "Hello World" + assert verify_obj['title'] == "My Title" + assert verify_obj['notify_type'] == common.NotifyType.INFO + assert isinstance(verify_obj['attach'], AppriseAttachment) + assert len(verify_obj['attach']) == 2 + + # No format was defined + assert 'body_format' in verify_obj['kwargs'] + assert verify_obj['kwargs']['body_format'] is None + + # The meta argument allows us to further parse the URL parameters + # specified + assert isinstance(verify_obj['kwargs'], dict) + assert 'meta' in verify_obj['kwargs'] + assert isinstance(verify_obj['kwargs']['meta'], dict) + assert len(verify_obj['kwargs']['meta']) == 4 + assert 'tag' in verify_obj['kwargs']['meta'] + + assert 'asset' in verify_obj['kwargs']['meta'] + assert isinstance(verify_obj['kwargs']['meta']['asset'], AppriseAsset) + + assert verify_obj['kwargs']['meta']['schema'] == 'utiltest' + assert verify_obj['kwargs']['meta']['url'] == 'utiltest://' + + # Reset our verify object (so it can be populated again) + verify_obj = {} + + # We'll do another test now + assert aobj.notify( + "Hello Another World", title="My Other Title", + body_format=common.NotifyFormat.HTML, + notify_type=common.NotifyType.WARNING, + ) is True + + # Our content was populated after the notify() call + assert len(verify_obj) > 0 + assert verify_obj['body'] == "Hello Another World" + assert verify_obj['title'] == "My Other Title" + assert verify_obj['notify_type'] == common.NotifyType.WARNING + # We have no attachments + assert verify_obj['attach'] is None + + # No format was defined + assert 'body_format' in verify_obj['kwargs'] + assert verify_obj['kwargs']['body_format'] == common.NotifyFormat.HTML + + # The meta argument allows us to further parse the URL parameters + # specified + assert 'meta' in verify_obj['kwargs'] + assert isinstance(verify_obj['kwargs'], dict) + assert len(verify_obj['kwargs']['meta']) == 4 + assert 'asset' in verify_obj['kwargs']['meta'] + assert isinstance(verify_obj['kwargs']['meta']['asset'], AppriseAsset) + assert 'tag' in verify_obj['kwargs']['meta'] + assert isinstance(verify_obj['kwargs']['meta']['tag'], set) + assert verify_obj['kwargs']['meta']['schema'] == 'utiltest' + assert verify_obj['kwargs']['meta']['url'] == 'utiltest://' + + assert 'notexc' not in common.NOTIFY_SCHEMA_MAP + + # Define a function here on the spot + @notify(on="notexc", name="Apprise @notify Exception Handling") + def my_exception_inline_notify_wrapper( + body, title, notify_type, attach, *args, **kwargs): + raise ValueError("An exception was thrown!") + + assert 'notexc' in common.NOTIFY_SCHEMA_MAP + + # Create ourselves an apprise object + aobj = Apprise() + + assert aobj.add("notexc://") is True + + # Isn't handled + assert aobj.notify("Exceptions will be thrown!") is False + + # Tidy + del common.NOTIFY_SCHEMA_MAP['utiltest'] + del common.NOTIFY_SCHEMA_MAP['notexc'] + + +def test_notify_complex_decoration(): + """decorators: Test complex @notify + """ + + # Verify our schema we're about to declare doesn't already exist + # in our schema map: + assert 'utiltest' not in common.NOTIFY_SCHEMA_MAP + + verify_obj = {} + + # Define a function here on the spot + @notify(on="utiltest://user@myhost:23?key=value&NOT=CaseSensitive", + name="Apprise @notify Decorator Testing") + def my_inline_notify_wrapper( + body, title, notify_type, attach, *args, **kwargs): + + # Populate our object we can use to validate + verify_obj.update({ + 'body': body, + 'title': title, + 'notify_type': notify_type, + 'attach': attach, + 'args': args, + 'kwargs': kwargs, + }) + + # Now after our hook being inline... it's been loaded + assert 'utiltest' in common.NOTIFY_SCHEMA_MAP + + # Create ourselves an apprise object + aobj = Apprise() + + assert aobj.add("utiltest://") is True + + assert len(verify_obj) == 0 + + assert aobj.notify( + "Hello World", title="My Title", + # add some attachments too + attach=( + join(TEST_VAR_DIR, 'apprise-test.gif'), + join(TEST_VAR_DIR, 'apprise-test.png'), + ) + ) is True + + # Our content was populated after the notify() call + assert len(verify_obj) > 0 + assert verify_obj['body'] == "Hello World" + assert verify_obj['title'] == "My Title" + assert verify_obj['notify_type'] == common.NotifyType.INFO + assert isinstance(verify_obj['attach'], AppriseAttachment) + assert len(verify_obj['attach']) == 2 + + # No format was defined + assert 'body_format' in verify_obj['kwargs'] + assert verify_obj['kwargs']['body_format'] is None + + # The meta argument allows us to further parse the URL parameters + # specified + assert isinstance(verify_obj['kwargs'], dict) + assert 'meta' in verify_obj['kwargs'] + assert isinstance(verify_obj['kwargs']['meta'], dict) + + assert 'asset' in verify_obj['kwargs']['meta'] + assert isinstance(verify_obj['kwargs']['meta']['asset'], AppriseAsset) + + assert 'tag' in verify_obj['kwargs']['meta'] + assert isinstance(verify_obj['kwargs']['meta']['tag'], set) + + assert len(verify_obj['kwargs']['meta']) == 8 + # We carry all of our default arguments from the @notify's initialization + assert verify_obj['kwargs']['meta']['schema'] == 'utiltest' + + # Case sensitivity is lost on key assignment and always made lowercase + # however value case sensitivity is preseved. + # this is the assembled URL based on the combined values of the default + # parameters with values provided in the URL (user's configuration) + assert verify_obj['kwargs']['meta']['url'].startswith( + 'utiltest://user@myhost:23?') + + # We don't know where they get placed, so just search for their match + assert 'key=value' in verify_obj['kwargs']['meta']['url'] + assert 'not=CaseSensitive' in verify_obj['kwargs']['meta']['url'] + + # Reset our verify object (so it can be populated again) + verify_obj = {} + + # We'll do another test now + aobj = Apprise() + + assert aobj.add("utiltest://customhost?key=new&key2=another") is True + + assert len(verify_obj) == 0 + + # Send our notification + assert aobj.notify("Hello World", title="My Title") is True + + # Our content was populated after the notify() call + assert len(verify_obj) > 0 + assert verify_obj['body'] == "Hello World" + assert verify_obj['title'] == "My Title" + assert verify_obj['notify_type'] == common.NotifyType.INFO + assert verify_obj['attach'] is None + + # No format was defined + assert 'body_format' in verify_obj['kwargs'] + assert verify_obj['kwargs']['body_format'] is None + + # The meta argument allows us to further parse the URL parameters + # specified + assert 'meta' in verify_obj['kwargs'] + assert isinstance(verify_obj['kwargs'], dict) + assert len(verify_obj['kwargs']['meta']) == 8 + + # We carry all of our default arguments from the @notify's initialization + assert verify_obj['kwargs']['meta']['schema'] == 'utiltest' + # Our host get's correctly over-ridden + assert verify_obj['kwargs']['meta']['host'] == 'customhost' + + assert verify_obj['kwargs']['meta']['user'] == "user" + assert verify_obj['kwargs']['meta']['port'] == 23 + assert isinstance(verify_obj['kwargs']['meta']['qsd'], dict) + assert len(verify_obj['kwargs']['meta']['qsd']) == 3 + # our key is over-ridden + assert verify_obj['kwargs']['meta']['qsd']['key'] == 'new' + # Our other keys are preserved + assert verify_obj['kwargs']['meta']['qsd']['not'] == 'CaseSensitive' + # New keys are added + assert verify_obj['kwargs']['meta']['qsd']['key2'] == 'another' + + # Case sensitivity is lost on key assignment and always made lowercase + # however value case sensitivity is preseved. + # this is the assembled URL based on the combined values of the default + # parameters with values provided in the URL (user's configuration) + assert verify_obj['kwargs']['meta']['url'].startswith( + 'utiltest://user@customhost:23?') + + # We don't know where they get placed, so just search for their match + assert 'key=new' in verify_obj['kwargs']['meta']['url'] + assert 'not=CaseSensitive' in verify_obj['kwargs']['meta']['url'] + assert 'key2=another' in verify_obj['kwargs']['meta']['url'] + + # Tidy + del common.NOTIFY_SCHEMA_MAP['utiltest'] diff --git a/test/test_plugin_glib.py b/test/test_plugin_glib.py index ed823563..1bd34096 100644 --- a/test/test_plugin_glib.py +++ b/test/test_plugin_glib.py @@ -30,6 +30,7 @@ import mock import sys import types import apprise +from helpers import module_reload try: # Python v3.4+ @@ -125,17 +126,7 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, mock_mainloop.glib.DBusGMainLoop.side_effect = None mock_mainloop.glib.NativeMainLoop.side_effect = None - # The following libraries need to be reloaded to prevent - # TypeError: super(type, obj): obj must be an instance or subtype of type - # This is better explained in this StackOverflow post: - # https://stackoverflow.com/questions/31363311/\ - # any-way-to-manually-fix-operation-of-\ - # super-after-ipython-reload-avoiding-ty - # - reload(sys.modules['apprise.plugins.NotifyDBus']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyDBus') # Create our instance (identify all supported types) obj = apprise.Apprise.instantiate('dbus://', suppress_exceptions=False) @@ -376,18 +367,7 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, # Emulate require_version function: gi.require_version.side_effect = ImportError() - - # The following libraries need to be reloaded to prevent - # TypeError: super(type, obj): obj must be an instance or subtype of type - # This is better explained in this StackOverflow post: - # https://stackoverflow.com/questions/31363311/\ - # any-way-to-manually-fix-operation-of-\ - # super-after-ipython-reload-avoiding-ty - # - reload(sys.modules['apprise.plugins.NotifyDBus']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyDBus') # Create our instance obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) @@ -416,18 +396,7 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, # Emulate require_version function: gi.require_version.side_effect = ValueError() - - # The following libraries need to be reloaded to prevent - # TypeError: super(type, obj): obj must be an instance or subtype of type - # This is better explained in this StackOverflow post: - # https://stackoverflow.com/questions/31363311/\ - # any-way-to-manually-fix-operation-of-\ - # super-after-ipython-reload-avoiding-ty - # - reload(sys.modules['apprise.plugins.NotifyDBus']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyDBus') # Create our instance obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) @@ -447,10 +416,7 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, sys.modules['dbus'] = compile('raise ImportError()', 'dbus', 'exec') # Reload our modules - reload(sys.modules['apprise.plugins.NotifyDBus']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyDBus') # We can no longer instantiate an instance because dbus has been # officialy marked unavailable and thus the module is marked @@ -462,7 +428,4 @@ def test_plugin_dbus_general(mock_mainloop, mock_byte, mock_bytearray, # let's just put our old configuration back: sys.modules['dbus'] = _session_bus # Reload our modules - reload(sys.modules['apprise.plugins.NotifyDBus']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyDBus') diff --git a/test/test_plugin_gnome.py b/test/test_plugin_gnome.py index 39b73b84..98ecc071 100644 --- a/test/test_plugin_gnome.py +++ b/test/test_plugin_gnome.py @@ -29,19 +29,9 @@ import sys import types import pytest import apprise +from helpers import module_reload from apprise.plugins.NotifyGnome import GnomeUrgency -try: - # Python v3.4+ - from importlib import reload -except ImportError: - try: - # Python v3.0-v3.3 - from imp import reload - except ImportError: - # Python v2.7 - pass - # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) @@ -65,7 +55,7 @@ def test_plugin_gnome_general(): # for the purpose of testing and capture the handling of the # library when it is missing del sys.modules[gi_name] - reload(sys.modules['apprise.plugins.NotifyGnome']) + module_reload('NotifyGnome') # We need to fake our gnome environment for testing purposes since # the gi library isn't available in Travis CI @@ -100,18 +90,7 @@ def test_plugin_gnome_general(): notify_obj.show.return_value = True mock_notify.new.return_value = notify_obj mock_pixbuf.new_from_file.return_value = True - - # The following libraries need to be reloaded to prevent - # TypeError: super(type, obj): obj must be an instance or subtype of type - # This is better explained in this StackOverflow post: - # https://stackoverflow.com/questions/31363311/\ - # any-way-to-manually-fix-operation-of-\ - # super-after-ipython-reload-avoiding-ty - # - reload(sys.modules['apprise.plugins.NotifyGnome']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyGnome') # Create our instance obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False) @@ -293,18 +272,7 @@ def test_plugin_gnome_general(): # Emulate require_version function: gi.require_version.side_effect = ValueError() - - # The following libraries need to be reloaded to prevent - # TypeError: super(type, obj): obj must be an instance or subtype of type - # This is better explained in this StackOverflow post: - # https://stackoverflow.com/questions/31363311/\ - # any-way-to-manually-fix-operation-of-\ - # super-after-ipython-reload-avoiding-ty - # - reload(sys.modules['apprise.plugins.NotifyGnome']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyGnome') # We can now no longer load our instance # The object internally is marked disabled diff --git a/test/test_plugin_macosx.py b/test/test_plugin_macosx.py index 8ffd6294..2fc617dc 100644 --- a/test/test_plugin_macosx.py +++ b/test/test_plugin_macosx.py @@ -25,22 +25,11 @@ import os import six -import sys import mock +from helpers import module_reload import apprise -try: - # Python v3.4+ - from importlib import reload -except ImportError: - try: - # Python v3.0-v3.3 - from imp import reload - except ImportError: - # Python v2.7 - pass - # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) @@ -71,10 +60,7 @@ def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir): mock_popen.return_value = mock_cmd_response # Ensure our enviroment is loaded with this configuration - reload(sys.modules['apprise.plugins.NotifyMacOSX']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyMacOSX') # Point our object to our new temporary existing file apprise.plugins.NotifyMacOSX.notify_paths = (str(script), ) @@ -144,10 +130,7 @@ def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir): # Test case where we simply aren't on a mac mock_system.return_value = 'Linux' - reload(sys.modules['apprise.plugins.NotifyMacOSX']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyMacOSX') # Point our object to our new temporary existing file apprise.plugins.NotifyMacOSX.notify_paths = (str(script), ) @@ -162,10 +145,7 @@ def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir): # Now we must be Mac OS v10.8 or higher... mock_macver.return_value = ('10.7', ('', '', ''), '') - reload(sys.modules['apprise.plugins.NotifyMacOSX']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyMacOSX') # Point our object to our new temporary existing file apprise.plugins.NotifyMacOSX.notify_paths = (str(script), ) @@ -176,10 +156,7 @@ def test_plugin_macosx_general(mock_macver, mock_system, mock_popen, tmpdir): # A newer environment to test edge case where this is tested mock_macver.return_value = ('9.12', ('', '', ''), '') - reload(sys.modules['apprise.plugins.NotifyMacOSX']) - reload(sys.modules['apprise.plugins']) - reload(sys.modules['apprise.Apprise']) - reload(sys.modules['apprise']) + module_reload('NotifyMacOSX') # Point our object to our new temporary existing file apprise.plugins.NotifyMacOSX.notify_paths = (str(script), ) diff --git a/test/test_plugin_ntfy.py b/test/test_plugin_ntfy.py index f7a5c063..fa86fe67 100644 --- a/test/test_plugin_ntfy.py +++ b/test/test_plugin_ntfy.py @@ -398,11 +398,11 @@ def test_plugin_custom_ntfy_edge_cases(mock_post): assert results['password'] is None assert results['port'] is None assert results['host'] == 'localhost' - assert results['fullpath'] == '/topic1' - assert results['path'] == '/' - assert results['query'] == 'topic1' + assert results['fullpath'] == '/topic1/' + assert results['path'] == '/topic1/' + assert results['query'] is None assert results['schema'] == 'ntfy' - assert results['url'] == 'ntfy://localhost/topic1' + assert results['url'] == 'ntfy://localhost/topic1/' assert results['attach'] == 'http://example.com/file.jpg' assert results['filename'] == 'smoke.jpg'