Apprise @notify decorator/plugin support (#625)

This commit is contained in:
Chris Caron 2022-07-15 11:27:36 -04:00 committed by GitHub
parent 088aba1622
commit 75ad104e99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 2852 additions and 359 deletions

182
README.md
View File

@ -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
<!--ts-->
* [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)
<!--te-->
# 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<br />zulip://botname@Organization/Token/Stream<br />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<br/>sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN<br/>sns://AccessKeyID/AccessSecretKey/RegionName/Topic<br/>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<br/>kavenegar://FromPhoneNo@ApiKey/ToPhoneNo<br/>kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
| [MessageBird](https://github.com/caronc/apprise/wiki/Notify_messagebird) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo<br/>msgbird://ApiKey/FromPhoneNo/ToPhoneNo<br/>msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [MSG91](https://github.com/caronc/apprise/wiki/Notify_msg91) | msg91:// | (TCP) 443 | msg91://AuthKey/ToPhoneNo<br/>msg91://SenderID@AuthKey/ToPhoneNo<br/>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<br/>signal://hostname:port/FromPhoneNo/ToPhoneNo<br/>signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo<br/>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<br/>nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo<br/>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://<br />qt://<br />glib://<br />kde:// | n/a | dbus://<br />qt://<br />glib://<br />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<br />mailto://domain.com?user=userid&pass=password<br/>mailto://domain.com:2525?user=userid&pass=password<br />mailto://user@gmail.com&pass=password<br />mailto://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com<br />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<br />form://user@hostname<br />form://user:password@hostname:port<br />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<br />json://user@hostname<br />json://user:password@hostname:port<br />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<br />xml://user@hostname<br />xml://user:password@hostname:port<br />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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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',
]

View File

@ -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

View File

@ -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.')

View File

@ -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': <CustomNotifyWrapperPlugin>
# },
# 'schema2': {
# 'name': 'Custom schema name',
# 'fn_name': 'name_of_function_decorator_was_found_on',
# 'url': 'schema://any/additional/info/found/on/url'
# 'plugin': <CustomNotifyWrapperPlugin>
# }
# }
#
# Note: that the <CustomNotifyWrapperPlugin> 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

View File

@ -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()):

View File

@ -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

View File

@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import six
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]

View File

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from .notify import notify
__all__ = [
'notify'
]

View File

@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from .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': <AppriseAsset>,
'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': <AppriseAsset>,
'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

View File

@ -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.
#

View File

@ -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)',

View File

@ -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

View File

@ -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()])))

View File

@ -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<key>.*)\s*')
# Used for attempting to acquire the schema if the URL can't be parsed.
GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[a-z0-9]{2,9})://.*$', re.I)
# 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<schema>[a-z0-9]{2,9})(://(?P<base>.*))?$', 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<name>[_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

View File

@ -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):
<define your custom code here>
# 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

View File

@ -33,6 +33,14 @@ The Apprise options are as follows:
`-a`, `--attach=`<ATTACH-URL>:
Specify one or more file attachment locations.
`-P`, `--plugin-path=`<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=`<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):
<define your custom code here>
# 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**

View File

@ -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',
]

66
test/helpers/module.py Normal file
View File

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import re
import 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'])

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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/'

View File

@ -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()

View File

@ -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')

View File

@ -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):
"""

View File

@ -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"""

View File

@ -0,0 +1,310 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from 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']

View File

@ -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')

View File

@ -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

View File

@ -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), )

View File

@ -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'