Load Dynamic Libraries and Emoji Engine on Demand (#1020)

This commit is contained in:
Chris Caron 2023-12-27 14:23:13 -05:00 committed by GitHub
parent 34a26da4f4
commit 9dcf769397
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1943 additions and 1064 deletions

View File

@ -31,7 +31,7 @@ jobs:
# expanded using subsequent `include` items. # expanded using subsequent `include` items.
matrix: matrix:
os: ["ubuntu-latest"] os: ["ubuntu-latest"]
python-version: ["3.10"] python-version: ["3.11"]
bare: [false] bare: [false]
include: include:
@ -39,14 +39,14 @@ jobs:
# Within the `bare` environment, `all-plugin-requirements.txt` will NOT be # Within the `bare` environment, `all-plugin-requirements.txt` will NOT be
# installed, to verify the application also works without those dependencies. # installed, to verify the application also works without those dependencies.
- os: "ubuntu-latest" - os: "ubuntu-latest"
python-version: "3.10" python-version: "3.11"
bare: true bare: true
# Let's save resources and only build a single slot on macOS- and Windows. # Let's save resources and only build a single slot on macOS- and Windows.
- os: "macos-latest" - os: "macos-latest"
python-version: "3.10" python-version: "3.11"
- os: "windows-latest" - os: "windows-latest"
python-version: "3.10" python-version: "3.11"
# Test more available versions of CPython on Linux. # Test more available versions of CPython on Linux.
- os: "ubuntu-20.04" - os: "ubuntu-20.04"

View File

@ -33,6 +33,7 @@ from itertools import chain
from . import common from . import common
from .conversion import convert_between from .conversion import convert_between
from .utils import is_exclusive_match from .utils import is_exclusive_match
from .NotificationManager import NotificationManager
from .utils import parse_list from .utils import parse_list
from .utils import parse_urls from .utils import parse_urls
from .utils import cwe312_url from .utils import cwe312_url
@ -45,10 +46,12 @@ from .AppriseLocale import AppriseLocale
from .config.ConfigBase import ConfigBase from .config.ConfigBase import ConfigBase
from .plugins.NotifyBase import NotifyBase from .plugins.NotifyBase import NotifyBase
from . import plugins from . import plugins
from . import __version__ from . import __version__
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
class Apprise: class Apprise:
""" """
@ -138,7 +141,7 @@ class Apprise:
# We already have our result set # We already have our result set
results = url results = url
if results.get('schema') not in common.NOTIFY_SCHEMA_MAP: if results.get('schema') not in N_MGR:
# schema is a mandatory dictionary item as it is the only way # schema is a mandatory dictionary item as it is the only way
# we can index into our loaded plugins # we can index into our loaded plugins
logger.error('Dictionary does not include a "schema" entry.') logger.error('Dictionary does not include a "schema" entry.')
@ -161,7 +164,7 @@ class Apprise:
type(url)) type(url))
return None return None
if not common.NOTIFY_SCHEMA_MAP[results['schema']].enabled: if not N_MGR[results['schema']].enabled:
# #
# First Plugin Enable Check (Pre Initialization) # First Plugin Enable Check (Pre Initialization)
# #
@ -181,13 +184,12 @@ class Apprise:
try: try:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information # URL information
plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) plugin = N_MGR[results['schema']](**results)
# Create log entry of loaded URL # Create log entry of loaded URL
logger.debug( logger.debug(
'Loaded {} URL: {}'.format( 'Loaded {} URL: {}'.format(
common. N_MGR[results['schema']].service_name,
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
plugin.url(privacy=asset.secure_logging))) plugin.url(privacy=asset.secure_logging)))
except Exception: except Exception:
@ -198,15 +200,14 @@ class Apprise:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
logger.error( logger.error(
'Could not load {} URL: {}'.format( 'Could not load {} URL: {}'.format(
common. N_MGR[results['schema']].service_name,
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
loggable_url)) loggable_url))
return None return None
else: else:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information but don't wrap it in a try catch # URL information but don't wrap it in a try catch
plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) plugin = N_MGR[results['schema']](**results)
if not plugin.enabled: if not plugin.enabled:
# #
@ -690,7 +691,7 @@ class Apprise:
'asset': self.asset.details(), 'asset': self.asset.details(),
} }
for plugin in set(common.NOTIFY_SCHEMA_MAP.values()): for plugin in N_MGR.plugins():
# Iterate over our hashed plugins and dynamically build details on # Iterate over our hashed plugins and dynamically build details on
# their status: # their status:

View File

@ -33,7 +33,11 @@ from os.path import dirname
from os.path import isfile from os.path import isfile
from os.path import abspath from os.path import abspath
from .common import NotifyType from .common import NotifyType
from .utils import module_detection from .NotificationManager import NotificationManager
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
class AppriseAsset: class AppriseAsset:
@ -180,7 +184,7 @@ class AppriseAsset:
if plugin_paths: if plugin_paths:
# Load any decorated modules if defined # Load any decorated modules if defined
module_detection(plugin_paths) N_MGR.module_detection(plugin_paths)
def color(self, notify_type, color_type=None): def color(self, notify_type, color_type=None):
""" """

View File

@ -26,15 +26,18 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
from . import attachment
from . import URLBase from . import URLBase
from .attachment.AttachBase import AttachBase
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from .AttachmentManager import AttachmentManager
from .logger import logger from .logger import logger
from .common import ContentLocation from .common import ContentLocation
from .common import CONTENT_LOCATIONS from .common import CONTENT_LOCATIONS
from .common import ATTACHMENT_SCHEMA_MAP
from .utils import GET_SCHEMA_RE from .utils import GET_SCHEMA_RE
# Grant access to our Notification Manager Singleton
A_MGR = AttachmentManager()
class AppriseAttachment: class AppriseAttachment:
""" """
@ -139,7 +142,7 @@ class AppriseAttachment:
# prepare default asset # prepare default asset
asset = self.asset asset = self.asset
if isinstance(attachments, attachment.AttachBase): if isinstance(attachments, AttachBase):
# Go ahead and just add our attachments into our list # Go ahead and just add our attachments into our list
self.attachments.append(attachments) self.attachments.append(attachments)
return True return True
@ -169,7 +172,7 @@ class AppriseAttachment:
# returns None if it fails # returns None if it fails
instance = AppriseAttachment.instantiate( instance = AppriseAttachment.instantiate(
_attachment, asset=asset, cache=cache) _attachment, asset=asset, cache=cache)
if not isinstance(instance, attachment.AttachBase): if not isinstance(instance, AttachBase):
return_status = False return_status = False
continue continue
@ -178,7 +181,7 @@ class AppriseAttachment:
# append our content together # append our content together
instance = _attachment.attachments instance = _attachment.attachments
elif not isinstance(_attachment, attachment.AttachBase): elif not isinstance(_attachment, AttachBase):
logger.warning( logger.warning(
"An invalid attachment (type={}) was specified.".format( "An invalid attachment (type={}) was specified.".format(
type(_attachment))) type(_attachment)))
@ -228,7 +231,7 @@ class AppriseAttachment:
schema = GET_SCHEMA_RE.match(url) schema = GET_SCHEMA_RE.match(url)
if schema is None: if schema is None:
# Plan B is to assume we're dealing with a file # Plan B is to assume we're dealing with a file
schema = attachment.AttachFile.protocol schema = 'file'
url = '{}://{}'.format(schema, URLBase.quote(url)) url = '{}://{}'.format(schema, URLBase.quote(url))
else: else:
@ -236,13 +239,13 @@ class AppriseAttachment:
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
# Some basic validation # Some basic validation
if schema not in ATTACHMENT_SCHEMA_MAP: if schema not in A_MGR:
logger.warning('Unsupported schema {}.'.format(schema)) logger.warning('Unsupported schema {}.'.format(schema))
return None return None
# Parse our url details of the server object as dictionary containing # Parse our url details of the server object as dictionary containing
# all of the information parsed from our URL # all of the information parsed from our URL
results = ATTACHMENT_SCHEMA_MAP[schema].parse_url(url) results = A_MGR[schema].parse_url(url)
if not results: if not results:
# Failed to parse the server URL # Failed to parse the server URL
@ -261,8 +264,7 @@ class AppriseAttachment:
try: try:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information # URL information
attach_plugin = \ attach_plugin = A_MGR[results['schema']](**results)
ATTACHMENT_SCHEMA_MAP[results['schema']](**results)
except Exception: except Exception:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
@ -272,7 +274,7 @@ class AppriseAttachment:
else: else:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information but don't wrap it in a try catch # URL information but don't wrap it in a try catch
attach_plugin = ATTACHMENT_SCHEMA_MAP[results['schema']](**results) attach_plugin = A_MGR[results['schema']](**results)
return attach_plugin return attach_plugin

View File

@ -29,6 +29,7 @@
from . import config from . import config
from . import ConfigBase from . import ConfigBase
from . import CONFIG_FORMATS from . import CONFIG_FORMATS
from .ConfigurationManager import ConfigurationManager
from . import URLBase from . import URLBase
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from . import common from . import common
@ -37,6 +38,9 @@ from .utils import parse_list
from .utils import is_exclusive_match from .utils import is_exclusive_match
from .logger import logger from .logger import logger
# Grant access to our Configuration Manager Singleton
C_MGR = ConfigurationManager()
class AppriseConfig: class AppriseConfig:
""" """
@ -251,7 +255,7 @@ class AppriseConfig:
logger.debug("Loading raw configuration: {}".format(content)) logger.debug("Loading raw configuration: {}".format(content))
# Create ourselves a ConfigMemory Object to store our configuration # Create ourselves a ConfigMemory Object to store our configuration
instance = config.ConfigMemory( instance = config.ConfigMemory.ConfigMemory(
content=content, format=format, asset=asset, tag=tag, content=content, format=format, asset=asset, tag=tag,
recursion=recursion, insecure_includes=insecure_includes) recursion=recursion, insecure_includes=insecure_includes)
@ -326,7 +330,7 @@ class AppriseConfig:
schema = GET_SCHEMA_RE.match(url) schema = GET_SCHEMA_RE.match(url)
if schema is None: if schema is None:
# Plan B is to assume we're dealing with a file # Plan B is to assume we're dealing with a file
schema = config.ConfigFile.protocol schema = config.ConfigFile.ConfigFile.protocol
url = '{}://{}'.format(schema, URLBase.quote(url)) url = '{}://{}'.format(schema, URLBase.quote(url))
else: else:
@ -334,13 +338,13 @@ class AppriseConfig:
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
# Some basic validation # Some basic validation
if schema not in common.CONFIG_SCHEMA_MAP: if schema not in C_MGR:
logger.warning('Unsupported schema {}.'.format(schema)) logger.warning('Unsupported schema {}.'.format(schema))
return None return None
# Parse our url details of the server object as dictionary containing # Parse our url details of the server object as dictionary containing
# all of the information parsed from our URL # all of the information parsed from our URL
results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) results = C_MGR[schema].parse_url(url)
if not results: if not results:
# Failed to parse the server URL # Failed to parse the server URL
@ -368,8 +372,7 @@ class AppriseConfig:
try: try:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information # URL information
cfg_plugin = \ cfg_plugin = C_MGR[results['schema']](**results)
common.CONFIG_SCHEMA_MAP[results['schema']](**results)
except Exception: except Exception:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
@ -379,7 +382,7 @@ class AppriseConfig:
else: else:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information but don't wrap it in a try catch # URL information but don't wrap it in a try catch
cfg_plugin = common.CONFIG_SCHEMA_MAP[results['schema']](**results) cfg_plugin = C_MGR[results['schema']](**results)
return cfg_plugin return cfg_plugin

View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from os.path import dirname
from os.path import abspath
from os.path import join
from .manager import PluginManager
class AttachmentManager(PluginManager):
"""
Designed to be a singleton object to maintain all initialized
attachment plugins/modules in memory.
"""
# Description (used for logging)
name = 'Attachment Plugin'
# Filename Prefix to filter on
fname_prefix = 'Attach'
# Memory Space
_id = 'attachment'
# Our Module Python path name
module_name_prefix = f'apprise.{_id}'
# The module path to scan
module_path = join(abspath(dirname(__file__)), _id)

View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from os.path import dirname
from os.path import abspath
from os.path import join
from .manager import PluginManager
class ConfigurationManager(PluginManager):
"""
Designed to be a singleton object to maintain all initialized
configuration plugins/modules in memory.
"""
# Description (used for logging)
name = 'Configuration Plugin'
# Filename Prefix to filter on
fname_prefix = 'Config'
# Memory Space
_id = 'config'
# Our Module Python path name
module_name_prefix = f'apprise.{_id}'
# The module path to scan
module_path = join(abspath(dirname(__file__)), _id)

View File

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from os.path import dirname
from os.path import abspath
from os.path import join
from .manager import PluginManager
class NotificationManager(PluginManager):
"""
Designed to be a singleton object to maintain all initialized notifications
in memory.
"""
# Description (used for logging)
name = 'Notification Plugin'
# Filename Prefix to filter on
fname_prefix = 'Notify'
# Memory Space
_id = 'plugins'
# Our Module Python path name
module_name_prefix = f'apprise.{_id}'
# The module path to scan
module_path = join(abspath(dirname(__file__)), _id)

View File

@ -26,93 +26,14 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
import re # Used for testing
from os import listdir from .AttachBase import AttachBase
from os.path import dirname from ..AttachmentManager import AttachmentManager
from os.path import abspath
from ..common import ATTACHMENT_SCHEMA_MAP
__all__ = [] # Initalize our Attachment Manager Singleton
A_MGR = AttachmentManager()
__all__ = [
# Load our Lookup Matrix # Reference
def __load_matrix(path=abspath(dirname(__file__)), name='apprise.attachment'): 'AttachBase',
""" ]
Dynamically load our schema map; this allows us to gracefully
skip over modules we simply don't have the dependencies for.
"""
# Used for the detection of additional Attachment Services objects
# The .py extension is optional as we support loading directories too
module_re = re.compile(r'^(?P<name>Attach[a-z0-9]+)(\.py)?$', re.I)
for f in listdir(path):
match = module_re.match(f)
if not match:
# keep going
continue
# Store our notification/plugin name:
plugin_name = match.group('name')
try:
module = __import__(
'{}.{}'.format(name, plugin_name),
globals(), locals(),
fromlist=[plugin_name])
except ImportError:
# No problem, we can't use this object
continue
if not hasattr(module, plugin_name):
# Not a library we can load as it doesn't follow the simple rule
# that the class must bear the same name as the notification
# file itself.
continue
# Get our plugin
plugin = getattr(module, plugin_name)
if not hasattr(plugin, 'app_id'):
# Filter out non-notification modules
continue
elif plugin_name in __all__:
# we're already handling this object
continue
# Add our module name to our __all__
__all__.append(plugin_name)
# Ensure we provide the class as the reference to this directory and
# not the module:
globals()[plugin_name] = plugin
# Load protocol(s) if defined
proto = getattr(plugin, 'protocol', None)
if isinstance(proto, str):
if proto not in ATTACHMENT_SCHEMA_MAP:
ATTACHMENT_SCHEMA_MAP[proto] = plugin
elif isinstance(proto, (set, list, tuple)):
# Support iterables list types
for p in proto:
if p not in ATTACHMENT_SCHEMA_MAP:
ATTACHMENT_SCHEMA_MAP[p] = plugin
# Load secure protocol(s) if defined
protos = getattr(plugin, 'secure_protocol', None)
if isinstance(protos, str):
if protos not in ATTACHMENT_SCHEMA_MAP:
ATTACHMENT_SCHEMA_MAP[protos] = plugin
if isinstance(protos, (set, list, tuple)):
# Support iterables list types
for p in protos:
if p not in ATTACHMENT_SCHEMA_MAP:
ATTACHMENT_SCHEMA_MAP[p] = plugin
return ATTACHMENT_SCHEMA_MAP
# Dynamically build our schema base
__load_matrix()

View File

@ -26,50 +26,6 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
# 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: class NotifyType:
""" """

View File

@ -35,16 +35,24 @@ from .. import plugins
from .. import common from .. import common
from ..AppriseAsset import AppriseAsset from ..AppriseAsset import AppriseAsset
from ..URLBase import URLBase from ..URLBase import URLBase
from ..ConfigurationManager import ConfigurationManager
from ..utils import GET_SCHEMA_RE from ..utils import GET_SCHEMA_RE
from ..utils import parse_list from ..utils import parse_list
from ..utils import parse_bool from ..utils import parse_bool
from ..utils import parse_urls from ..utils import parse_urls
from ..utils import cwe312_url from ..utils import cwe312_url
from ..NotificationManager import NotificationManager
# Test whether token is valid or not # Test whether token is valid or not
VALID_TOKEN = re.compile( VALID_TOKEN = re.compile(
r'(?P<token>[a-z0-9][a-z0-9_]+)', re.I) r'(?P<token>[a-z0-9][a-z0-9_]+)', re.I)
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
# Grant access to our Configuration Manager Singleton
C_MGR = ConfigurationManager()
class ConfigBase(URLBase): class ConfigBase(URLBase):
""" """
@ -229,7 +237,7 @@ class ConfigBase(URLBase):
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
# Some basic validation # Some basic validation
if schema not in common.CONFIG_SCHEMA_MAP: if schema not in C_MGR:
ConfigBase.logger.warning( ConfigBase.logger.warning(
'Unsupported include schema {}.'.format(schema)) 'Unsupported include schema {}.'.format(schema))
continue continue
@ -240,7 +248,7 @@ class ConfigBase(URLBase):
# Parse our url details of the server object as dictionary # Parse our url details of the server object as dictionary
# containing all of the information parsed from our URL # containing all of the information parsed from our URL
results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) results = C_MGR[schema].parse_url(url)
if not results: if not results:
# Failed to parse the server URL # Failed to parse the server URL
self.logger.warning( self.logger.warning(
@ -248,11 +256,10 @@ class ConfigBase(URLBase):
continue continue
# Handle cross inclusion based on allow_cross_includes rules # Handle cross inclusion based on allow_cross_includes rules
if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes == if (C_MGR[schema].allow_cross_includes ==
common.ContentIncludeMode.STRICT common.ContentIncludeMode.STRICT
and schema not in self.schemas() and schema not in self.schemas()
and not self.insecure_includes) or \ and not self.insecure_includes) or C_MGR[schema] \
common.CONFIG_SCHEMA_MAP[schema] \
.allow_cross_includes == \ .allow_cross_includes == \
common.ContentIncludeMode.NEVER: common.ContentIncludeMode.NEVER:
@ -280,8 +287,7 @@ class ConfigBase(URLBase):
try: try:
# Attempt to create an instance of our plugin using the # Attempt to create an instance of our plugin using the
# parsed URL information # parsed URL information
cfg_plugin = \ cfg_plugin = C_MGR[results['schema']](**results)
common.CONFIG_SCHEMA_MAP[results['schema']](**results)
except Exception as e: except Exception as e:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
@ -765,8 +771,7 @@ class ConfigBase(URLBase):
try: try:
# Attempt to create an instance of our plugin using the # Attempt to create an instance of our plugin using the
# parsed URL information # parsed URL information
plugin = common.NOTIFY_SCHEMA_MAP[ plugin = N_MGR[results['schema']](**results)
results['schema']](**results)
# Create log entry of loaded URL # Create log entry of loaded URL
ConfigBase.logger.debug( ConfigBase.logger.debug(
@ -1094,7 +1099,7 @@ class ConfigBase(URLBase):
del entries['schema'] del entries['schema']
# support our special tokens (if they're present) # support our special tokens (if they're present)
if schema in common.NOTIFY_SCHEMA_MAP: if schema in N_MGR:
entries = ConfigBase._special_token_handler( entries = ConfigBase._special_token_handler(
schema, entries) schema, entries)
@ -1106,7 +1111,7 @@ class ConfigBase(URLBase):
elif isinstance(tokens, dict): elif isinstance(tokens, dict):
# support our special tokens (if they're present) # support our special tokens (if they're present)
if schema in common.NOTIFY_SCHEMA_MAP: if schema in N_MGR:
tokens = ConfigBase._special_token_handler( tokens = ConfigBase._special_token_handler(
schema, tokens) schema, tokens)
@ -1140,7 +1145,7 @@ class ConfigBase(URLBase):
# Grab our first item # Grab our first item
_results = results.pop(0) _results = results.pop(0)
if _results['schema'] not in common.NOTIFY_SCHEMA_MAP: if _results['schema'] not in N_MGR:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
ConfigBase.logger.warning( ConfigBase.logger.warning(
'An invalid Apprise schema ({}) in YAML configuration ' 'An invalid Apprise schema ({}) in YAML configuration '
@ -1213,8 +1218,7 @@ class ConfigBase(URLBase):
try: try:
# Attempt to create an instance of our plugin using the # Attempt to create an instance of our plugin using the
# parsed URL information # parsed URL information
plugin = common.\ plugin = N_MGR[results['schema']](**results)
NOTIFY_SCHEMA_MAP[results['schema']](**results)
# Create log entry of loaded URL # Create log entry of loaded URL
ConfigBase.logger.debug( ConfigBase.logger.debug(
@ -1267,8 +1271,7 @@ class ConfigBase(URLBase):
# Create a copy of our dictionary # Create a copy of our dictionary
tokens = tokens.copy() tokens = tokens.copy()
for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\ for kw, meta in N_MGR[schema].template_kwargs.items():
.template_kwargs.items():
# Determine our prefix: # Determine our prefix:
prefix = meta.get('prefix', '+') prefix = meta.get('prefix', '+')
@ -1311,8 +1314,7 @@ class ConfigBase(URLBase):
# #
# This function here allows these mappings to take place within the # This function here allows these mappings to take place within the
# YAML file as independant arguments. # YAML file as independant arguments.
class_templates = \ class_templates = plugins.details(N_MGR[schema])
plugins.details(common.NOTIFY_SCHEMA_MAP[schema])
for key in list(tokens.keys()): for key in list(tokens.keys()):

View File

@ -26,84 +26,14 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
import re # Used for testing
from os import listdir from .ConfigBase import ConfigBase
from os.path import dirname from ..ConfigurationManager import ConfigurationManager
from os.path import abspath
from ..logger import logger
from ..common import CONFIG_SCHEMA_MAP
__all__ = [] # Initalize our Config Manager Singleton
C_MGR = ConfigurationManager()
__all__ = [
# Load our Lookup Matrix # Reference
def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'): 'ConfigBase',
""" ]
Dynamically load our schema map; this allows us to gracefully
skip over modules we simply don't have the dependencies for.
"""
# Used for the detection of additional Configuration Services objects
# The .py extension is optional as we support loading directories too
module_re = re.compile(r'^(?P<name>Config[a-z0-9]+)(\.py)?$', re.I)
for f in listdir(path):
match = module_re.match(f)
if not match:
# keep going
continue
# Store our notification/plugin name:
plugin_name = match.group('name')
try:
module = __import__(
'{}.{}'.format(name, plugin_name),
globals(), locals(),
fromlist=[plugin_name])
except ImportError:
# No problem, we can't use this object
continue
if not hasattr(module, plugin_name):
# Not a library we can load as it doesn't follow the simple rule
# that the class must bear the same name as the notification
# file itself.
continue
# Get our plugin
plugin = getattr(module, plugin_name)
if not hasattr(plugin, 'app_id'):
# Filter out non-notification modules
continue
elif plugin_name in __all__:
# we're already handling this object
continue
# Add our module name to our __all__
__all__.append(plugin_name)
# Ensure we provide the class as the reference to this directory and
# not the module:
globals()[plugin_name] = plugin
fn = getattr(plugin, 'schemas', None)
schemas = set([]) if not callable(fn) else fn(plugin)
# map our schema to our plugin
for schema in schemas:
if schema in CONFIG_SCHEMA_MAP:
logger.error(
"Config schema ({}) mismatch detected - {} to {}"
.format(schema, CONFIG_SCHEMA_MAP[schema], plugin))
continue
# Assign plugin
CONFIG_SCHEMA_MAP[schema] = plugin
return CONFIG_SCHEMA_MAP
# Dynamically build our schema base
__load_matrix()

View File

@ -28,6 +28,7 @@
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
from ..plugins.NotifyBase import NotifyBase from ..plugins.NotifyBase import NotifyBase
from ..NotificationManager import NotificationManager
from ..utils import URL_DETAILS_RE from ..utils import URL_DETAILS_RE
from ..utils import parse_url from ..utils import parse_url
from ..utils import url_assembly from ..utils import url_assembly
@ -36,6 +37,9 @@ from .. import common
from ..logger import logger from ..logger import logger
import inspect import inspect
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
class CustomNotifyPlugin(NotifyBase): class CustomNotifyPlugin(NotifyBase):
""" """
@ -91,17 +95,17 @@ class CustomNotifyPlugin(NotifyBase):
logger.warning(msg) logger.warning(msg)
return None return None
# Acquire our plugin name # Acquire our schema
plugin_name = re_match.group('schema').lower() schema = re_match.group('schema').lower()
if not re_match.group('base'): if not re_match.group('base'):
url = '{}://'.format(plugin_name) url = '{}://'.format(schema)
# Keep a default set of arguments to apply to all called references # Keep a default set of arguments to apply to all called references
base_args = parse_url( base_args = parse_url(
url, default_schema=plugin_name, verify_host=False, simple=True) url, default_schema=schema, verify_host=False, simple=True)
if plugin_name in common.NOTIFY_SCHEMA_MAP: if schema in N_MGR:
# we're already handling this object # we're already handling this object
msg = 'The schema ({}) is already defined and could not be ' \ msg = 'The schema ({}) is already defined and could not be ' \
'loaded from custom notify function {}.' \ 'loaded from custom notify function {}.' \
@ -117,10 +121,10 @@ class CustomNotifyPlugin(NotifyBase):
# Our Service Name # Our Service Name
service_name = name if isinstance(name, str) \ service_name = name if isinstance(name, str) \
and name else 'Custom - {}'.format(plugin_name) and name else 'Custom - {}'.format(schema)
# Store our matched schema # Store our matched schema
secure_protocol = plugin_name secure_protocol = schema
requirements = { requirements = {
# Define our required packaging in order to work # Define our required packaging in order to work
@ -181,51 +185,26 @@ class CustomNotifyPlugin(NotifyBase):
# Unhandled Exception # Unhandled Exception
self.logger.warning( self.logger.warning(
'An exception occured sending a %s notification.', 'An exception occured sending a %s notification.',
common. N_MGR[self.secure_protocol].service_name)
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
self.logger.debug( self.logger.debug(
'%s Exception: %s', '%s Exception: %s',
common.NOTIFY_SCHEMA_MAP[self.secure_protocol], str(e)) N_MGR[self.secure_protocol], str(e))
return False return False
if response: if response:
self.logger.info( self.logger.info(
'Sent %s notification.', 'Sent %s notification.',
common. N_MGR[self.secure_protocol].service_name)
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
else: else:
self.logger.warning( self.logger.warning(
'Failed to send %s notification.', 'Failed to send %s notification.',
common. N_MGR[self.secure_protocol].service_name)
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
return response return response
# Store our plugin into our core map file # Store our plugin into our core map file
common.NOTIFY_SCHEMA_MAP[plugin_name] = CustomNotifyPluginWrapper return N_MGR.add(
plugin=CustomNotifyPluginWrapper,
# Update our custom plugin map schemas=schema,
module_pyname = str(send_func.__module__) send_func=send_func,
if module_pyname not in common.NOTIFY_CUSTOM_MODULE_MAP: url=url,
# 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

@ -27,6 +27,8 @@
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
import re import re
import time
from .logger import logger
# All Emoji's are wrapped in this character # All Emoji's are wrapped in this character
DELIM = ':' DELIM = ':'
@ -2242,10 +2244,8 @@ EMOJI_MAP = {
DELIM + r'wales' + DELIM: '🏴󠁧󠁢󠁷󠁬󠁳󠁿', DELIM + r'wales' + DELIM: '🏴󠁧󠁢󠁷󠁬󠁳󠁿',
} }
# Our compiled mapping # Define our singlton
EMOJI_COMPILED_MAP = re.compile( EMOJI_COMPILED_MAP = None
r'(' + '|'.join(EMOJI_MAP.keys()) + r')',
re.IGNORECASE)
def apply_emojis(content): def apply_emojis(content):
@ -2254,6 +2254,17 @@ def apply_emojis(content):
utf-8 encoded mapping utf-8 encoded mapping
""" """
global EMOJI_COMPILED_MAP
if EMOJI_COMPILED_MAP is None:
t_start = time.time()
# Perform our compilation
EMOJI_COMPILED_MAP = re.compile(
r'(' + '|'.join(EMOJI_MAP.keys()) + r')',
re.IGNORECASE)
logger.trace(
'Emoji engine loaded in {:.4f}s'.format((time.time() - t_start)))
try: try:
return EMOJI_COMPILED_MAP.sub(lambda x: EMOJI_MAP[x.group()], content) return EMOJI_COMPILED_MAP.sub(lambda x: EMOJI_MAP[x.group()], content)

718
apprise/manager.py Normal file
View File

@ -0,0 +1,718 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import os
import re
import sys
import time
import hashlib
import inspect
from .utils import import_module
from .utils import Singleton
from .utils import parse_list
from os.path import dirname
from os.path import abspath
from os.path import join
from .logger import logger
class PluginManager(metaclass=Singleton):
"""
Designed to be a singleton object to maintain all initialized loading
of modules in memory.
"""
# Description (used for logging)
name = 'Singleton Plugin'
# Memory Space
_id = 'undefined'
# Our Module Python path name
module_name_prefix = f'apprise.{_id}'
# The module path to scan
module_path = join(abspath(dirname(__file__)), _id)
def __init__(self, *args, **kwargs):
"""
Over-ride our class instantiation to provide a singleton
"""
self._module_map = None
self._schema_map = None
# 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 _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
self._custom_module_map = {}
# Track manually disabled modules (by their schema)
self._disabled = set()
# Hash of all paths previously scanned so we don't waste
# effort/overhead doing it again
self._paths_previously_scanned = set()
def unload_modules(self, disable_native=False):
"""
Reset our object and unload all modules
"""
if self._custom_module_map:
# Handle Custom Module Assignments
for meta in self._custom_module_map.values():
if meta['name'] not in self._module_map:
# Nothing to remove
continue
# For the purpose of tidying up un-used modules in memory
loaded = [m for m in sys.modules.keys()
if m.startswith(
self._module_map[meta['name']]['path'])]
for module_path in loaded:
del sys.modules[module_path]
# Reset disabled plugins (if any)
for schema in self._disabled:
self._schema_map[schema].enabled = True
self._disabled.clear()
# Reset our variables
self._module_map = None if not disable_native else {}
self._schema_map = {}
self._custom_module_map = {}
# Reset our path cache
self._paths_previously_scanned = set()
def load_modules(self, path=None, name=None):
"""
Load our modules into memory
"""
# Default value
module_name_prefix = self.module_name_prefix if name is None else name
module_path = self.module_path if path is None else path
if not self:
# Initialize our maps
self._module_map = {}
self._schema_map = {}
self._custom_module_map = {}
# Used for the detection of additional Notify Services objects
# The .py extension is optional as we support loading directories too
module_re = re.compile(
r'^(?P<name>' + self.fname_prefix + r'[a-z0-9]+)(\.py)?$', re.I)
t_start = time.time()
for f in os.listdir(module_path):
tl_start = time.time()
match = module_re.match(f)
if not match:
# keep going
continue
elif match.group('name') == f'{self.fname_prefix}Base':
# keep going
continue
# Store our notification/plugin name:
module_name = match.group('name')
module_pyname = '{}.{}'.format(module_name_prefix, module_name)
if module_name in self._module_map:
logger.warning(
"%s(s) (%s) already loaded; ignoring %s",
self.name, module_name, os.path.join(module_path, f))
continue
try:
module = __import__(
module_pyname,
globals(), locals(),
fromlist=[module_name])
except ImportError:
# No problem, we can try again another way...
module = import_module(
os.path.join(module_path, f), module_pyname)
if not module:
# logging found in import_module and not needed here
continue
if not hasattr(module, module_name):
# Not a library we can load as it doesn't follow the simple
# rule that the class must bear the same name as the
# notification file itself.
logger.trace(
"%s (%s) import failed; no filename/Class "
"match found in %s",
self.name, module_name, os.path.join(module_path, f))
continue
# Get our plugin
plugin = getattr(module, module_name)
if not hasattr(plugin, 'app_id'):
# Filter out non-notification modules
logger.trace(
"(%s) import failed; no app_id defined in %s",
self.name, module_name, os.path.join(module_path, f))
continue
# Add our plugin name to our module map
self._module_map[module_name] = {
'plugin': set([plugin]),
'module': module,
'path': '{}.{}'.format(module_name_prefix, module_name),
'native': True,
}
fn = getattr(plugin, 'schemas', None)
schemas = set([]) if not callable(fn) else fn(plugin)
# map our schema to our plugin
for schema in schemas:
if schema in self._schema_map:
logger.error(
"{} schema ({}) mismatch detected - {} to {}"
.format(self.name, schema, self._schema_map, plugin))
continue
# Assign plugin
self._schema_map[schema] = plugin
logger.trace(
'{} {} loaded in {:.6f}s'.format(
self.name, module_name, (time.time() - tl_start)))
logger.debug(
'{} {}(s) and {} Schema(s) loaded in {:.4f}s'
.format(
self.name, len(self._module_map), len(self._schema_map),
(time.time() - t_start)))
def module_detection(self, paths, cache=True):
"""
Leverage the @notify decorator and load all objects found matching
this.
"""
# 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 for __init__.py as well
module_re = re.compile(
r'^(?P<name>[_a-z0-9][a-z0-9._-]+)?(\.py)?$', re.I)
# Validate if we're a loadable Python file or not
valid_python_file_re = re.compile(r'.+\.py(o|c)?$', re.IGNORECASE)
if isinstance(paths, str):
paths = [paths, ]
if not paths or not isinstance(paths, (tuple, list)):
# We're done
return
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
if not (path and valid_python_file_re.match(path)):
# Ignore file/module type
logger.trace('Plugin Scan: Skipping %s', path)
return
t_start = time.time()
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 self._custom_module_map:
# First clear out existing entries
for schema in \
self._custom_module_map[module_pyname]['notify']\
.keys():
# Remove any mapped modules to this file
del self._schema_map[schema]
# Reset
del self._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
# Print our loaded modules if any
if module_pyname in self._custom_module_map:
logger.debug(
'Custom module %s - %d schema(s) (name=%s) '
'loaded in {:.6f}s',
_path, module_name,
len(self._custom_module_map[module_pyname]['notify']),
(time.time() - t_start))
# Add our plugin name to our module map
self._module_map[module_name] = {
'plugin': set(),
'module': module,
'path': module_pyname,
'native': False,
}
for schema, meta in\
self._custom_module_map[module_pyname]['notify']\
.items():
# For mapping purposes; map our element in our main list
self._module_map[module_name]['plugin'].add(
self._schema_map[schema])
# Log our success
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
# end of _import_module()
return
for _path in paths:
path = os.path.abspath(os.path.expanduser(_path))
if (cache and path in self._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
self._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 self._paths_previously_scanned):
# Load our module
_import_module(new_path)
# Add our subdir path
self._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 self._paths_previously_scanned:
continue
self._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 add(self, plugin, schemas=None, url=None, send_func=None):
"""
Ability to manually add Notification services to our stack
"""
if not self:
# Lazy load
self.load_modules()
# Acquire a list of schemas
p_schemas = parse_list(plugin.secure_protocol, plugin.protocol)
if isinstance(schemas, str):
schemas = [schemas, ]
elif schemas is None:
# Default
schemas = p_schemas
if not schemas or not isinstance(schemas, (set, tuple, list)):
# We're done
logger.error(
'The schemas provided (type %s) is unsupported; '
'loaded from %s.',
type(schemas),
send_func.__name__ if send_func else plugin.__class__.__name__)
return False
# Convert our schemas into a set
schemas = set([s.lower() for s in schemas]) | set(p_schemas)
# Valdation
conflict = [s for s in schemas if s in self]
if conflict:
# we're already handling this schema
logger.warning(
'The schema(s) (%s) are already defined and could not be '
'loaded from %s%s.',
', '.join(conflict),
'custom notify function ' if send_func else '',
send_func.__name__ if send_func else plugin.__class__.__name__)
return False
if send_func:
# Acquire the function name
fn_name = send_func.__name__
# Acquire the python filename path
path = inspect.getfile(send_func)
# Acquire our path to our module
module_name = str(send_func.__module__)
if module_name not in self._custom_module_map:
# Support non-dynamic includes as well...
self._custom_module_map[module_name] = {
# Name can be useful for indexing back into the
# _module_map object; this is the key to do it with:
'name': module_name.split('.')[-1],
# The path to the module loaded
'path': path,
# Initialize our template
'notify': {},
}
for schema in schemas:
self._custom_module_map[module_name]['notify'][schema] = {
# The name of the send function the @notify decorator
# wrapped
'fn_name': fn_name,
# The URL that was provided in the @notify decorator call
# associated with the 'on='
'url': url,
}
else:
module_name = hashlib.sha1(
''.join(schemas).encode('utf-8')).hexdigest()
module_pyname = "{prefix}.{name}".format(
prefix='apprise.adhoc.module', name=module_name)
# Add our plugin name to our module map
self._module_map[module_name] = {
'plugin': set([plugin]),
'module': None,
'path': module_pyname,
'native': False,
}
for schema in schemas:
# Assign our mapping
self._schema_map[schema] = plugin
return True
def remove(self, *schemas):
"""
Removes a loaded element (if defined)
"""
if not self:
# Lazy load
self.load_modules()
for schema in schemas:
try:
del self[schema]
except KeyError:
pass
def plugins(self, include_disabled=True):
"""
Return all of our loaded plugins
"""
if not self:
# Lazy load
self.load_modules()
for module in self._module_map.values():
for plugin in module['plugin']:
if not include_disabled and not plugin.enabled:
continue
yield plugin
def schemas(self, include_disabled=True):
"""
Return all of our loaded schemas
if include_disabled == True, then even disabled notifications are
returned
"""
if not self:
# Lazy load
self.load_modules()
# Return our list
return list(self._schema_map.keys()) if include_disabled else \
[s for s in self._schema_map.keys() if self._schema_map[s].enabled]
def disable(self, *schemas):
"""
Disables the modules associated with the specified schemas
"""
if not self:
# Lazy load
self.load_modules()
for schema in schemas:
if schema not in self._schema_map:
continue
if not self._schema_map[schema].enabled:
continue
# Disable
self._schema_map[schema].enabled = False
self._disabled.add(schema)
def enable_only(self, *schemas):
"""
Disables the modules associated with the specified schemas
"""
if not self:
# Lazy load
self.load_modules()
# convert to set for faster indexing
schemas = set(schemas)
for plugin in self.plugins():
# Get our plugin's schema list
p_schemas = set(
parse_list(plugin.secure_protocol, plugin.protocol))
if not schemas & p_schemas:
if plugin.enabled:
# Disable it (only if previously enabled); this prevents us
# from adjusting schemas that were disabled due to missing
# libraries or other environment reasons
plugin.enabled = False
self._disabled |= p_schemas
continue
# If we reach here, our schema was flagged to be enabled
if p_schemas & self._disabled:
# Previously disabled; no worries, let's clear this up
self._disabled -= p_schemas
plugin.enabled = True
def __contains__(self, schema):
"""
Checks if a schema exists
"""
if not self:
# Lazy load
self.load_modules()
return schema in self._schema_map
def __delitem__(self, schema):
if not self:
# Lazy load
self.load_modules()
# Get our plugin (otherwise we throw a KeyError) which is
# intended on del action that doesn't align
plugin = self._schema_map[schema]
# Our list of all schema entries
p_schemas = set([schema])
for key in list(self._module_map.keys()):
if plugin in self._module_map[key]['plugin']:
# Remove our plugin
self._module_map[key]['plugin'].remove(plugin)
# Custom Plugin Entry; Clean up cross reference
module_pyname = self._module_map[key]['path']
if not self._module_map[key]['native'] and \
module_pyname in self._custom_module_map:
del self.\
_custom_module_map[module_pyname]['notify'][schema]
if not self._custom_module_map[module_pyname]['notify']:
#
# Last custom loaded element
#
# Free up custom object entry
del self._custom_module_map[module_pyname]
if not self._module_map[key]['plugin']:
#
# Last element
#
if self._module_map[key]['native']:
# Get our plugin's schema list
p_schemas = \
set([s for s in parse_list(
plugin.secure_protocol, plugin.protocol)
if s in self._schema_map])
# free system memory
if self._module_map[key]['module']:
del sys.modules[self._module_map[key]['path']]
# free last remaining pointer in module map
del self._module_map[key]
for schema in p_schemas:
# Final Tidy
del self._schema_map[schema]
def __setitem__(self, schema, plugin):
"""
Support fast assigning of Plugin/Notification Objects
"""
if not self:
# Lazy load
self.load_modules()
# Set default values if not otherwise set
if not plugin.service_name:
# Assign service name if one doesn't exist
plugin.service_name = f'{schema}://'
p_schemas = set(
parse_list(plugin.secure_protocol, plugin.protocol))
if not p_schemas:
# Assign our protocol
plugin.secure_protocol = schema
p_schemas.add(schema)
elif schema not in p_schemas:
# Add our others (if defined)
plugin.secure_protocol = \
set([schema] + parse_list(plugin.secure_protocol))
p_schemas.add(schema)
if not self.add(plugin, schemas=p_schemas):
raise KeyError('Conflicting Assignment')
def __getitem__(self, schema):
"""
Returns the indexed plugin identified by the schema specified
"""
if not self:
# Lazy load
self.load_modules()
return self._schema_map[schema]
def __iter__(self):
"""
Returns an iterator so we can iterate over our loaded modules
"""
if not self:
# Lazy load
self.load_modules()
return iter(self._module_map.values())
def __len__(self):
"""
Returns the number of modules/plugins loaded
"""
if not self:
# Lazy load
self.load_modules()
return len(self._module_map)
def __bool__(self):
"""
Determines if object has loaded or not
"""
return True if self._module_map is not None else False

View File

@ -40,7 +40,6 @@ from ..AppriseLocale import gettext_lazy as _
NOTIFY_MACOSX_SUPPORT_ENABLED = False NOTIFY_MACOSX_SUPPORT_ENABLED = False
# TODO: The module will be easier to test without module-level code.
if platform.system() == 'Darwin': if platform.system() == 'Darwin':
# Check this is Mac OS X 10.8, or higher # Check this is Mac OS X 10.8, or higher
major, minor = platform.mac_ver()[0].split('.')[:2] major, minor = platform.mac_ver()[0].split('.')[:2]

View File

@ -27,12 +27,8 @@
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
import os import os
import re
import copy import copy
from os.path import dirname
from os.path import abspath
# Used for testing # Used for testing
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
@ -40,13 +36,17 @@ from ..common import NotifyImageSize
from ..common import NOTIFY_IMAGE_SIZES from ..common import NOTIFY_IMAGE_SIZES
from ..common import NotifyType from ..common import NotifyType
from ..common import NOTIFY_TYPES from ..common import NOTIFY_TYPES
from .. import common
from ..utils import parse_list from ..utils import parse_list
from ..utils import cwe312_url from ..utils import cwe312_url
from ..utils import GET_SCHEMA_RE from ..utils import GET_SCHEMA_RE
from ..logger import logger from ..logger import logger
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
from ..AppriseLocale import LazyTranslation from ..AppriseLocale import LazyTranslation
from ..NotificationManager import NotificationManager
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
__all__ = [ __all__ = [
# Reference # Reference
@ -58,101 +58,6 @@ __all__ = [
] ]
# Load our Lookup Matrix
def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'):
"""
Dynamically load our schema map; this allows us to gracefully
skip over modules we simply don't have the dependencies for.
"""
# Used for the detection of additional Notify Services objects
# The .py extension is optional as we support loading directories too
module_re = re.compile(r'^(?P<name>Notify[a-z0-9]+)(\.py)?$', re.I)
for f in os.listdir(path):
match = module_re.match(f)
if not match:
# keep going
continue
# Store our notification/plugin name:
plugin_name = match.group('name')
try:
module = __import__(
'{}.{}'.format(name, plugin_name),
globals(), locals(),
fromlist=[plugin_name])
except ImportError:
# No problem, we can't use this object
continue
if not hasattr(module, plugin_name):
# Not a library we can load as it doesn't follow the simple rule
# that the class must bear the same name as the notification
# file itself.
continue
# Get our plugin
plugin = getattr(module, plugin_name)
if not hasattr(plugin, 'app_id'):
# Filter out non-notification modules
continue
elif plugin_name in common.NOTIFY_MODULE_MAP:
# we're already handling this object
continue
# Add our plugin name to our module map
common.NOTIFY_MODULE_MAP[plugin_name] = {
'plugin': plugin,
'module': module,
}
# Add our module name to our __all__
__all__.append(plugin_name)
fn = getattr(plugin, 'schemas', None)
schemas = set([]) if not callable(fn) else fn(plugin)
# map our schema to our plugin
for schema in schemas:
if schema in common.NOTIFY_SCHEMA_MAP:
logger.error(
"Notification schema ({}) mismatch detected - {} to {}"
.format(schema, common.NOTIFY_SCHEMA_MAP[schema], plugin))
continue
# Assign plugin
common.NOTIFY_SCHEMA_MAP[schema] = plugin
return common.NOTIFY_SCHEMA_MAP
# Reset our Lookup Matrix
def __reset_matrix():
"""
Restores the Lookup matrix to it's base setting. This is only used through
testing and should not be directly called.
"""
# Reset our schema map
common.NOTIFY_SCHEMA_MAP.clear()
# Iterate over our module map so we can clear out our __all__ and globals
for plugin_name in common.NOTIFY_MODULE_MAP.keys():
# Remove element from plugins
__all__.remove(plugin_name)
# Clear out our module map
common.NOTIFY_MODULE_MAP.clear()
# Dynamically build our schema base
__load_matrix()
def _sanitize_token(tokens, default_delimiter): def _sanitize_token(tokens, default_delimiter):
""" """
This is called by the details() function and santizes the output by This is called by the details() function and santizes the output by
@ -176,6 +81,10 @@ def _sanitize_token(tokens, default_delimiter):
# Do not touch this field # Do not touch this field
continue continue
elif 'name' not in tokens[key]:
# Default to key
tokens[key]['name'] = key
if 'map_to' not in tokens[key]: if 'map_to' not in tokens[key]:
# Default type to key # Default type to key
tokens[key]['map_to'] = key tokens[key]['map_to'] = key
@ -538,16 +447,16 @@ def url_to_dict(url, secure_logging=True):
# Ensure our schema is always in lower case # Ensure our schema is always in lower case
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
if schema not in common.NOTIFY_SCHEMA_MAP: if schema not in N_MGR:
# Give the user the benefit of the doubt that the user may be using # 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. # one of the URLs provided to them by their notification service.
# Before we fail for good, just scan all the plugins that support the # Before we fail for good, just scan all the plugins that support the
# native_url() parse function # native_url() parse function
results = \ results = None
next((r['plugin'].parse_native_url(_url) for plugin in N_MGR.plugins():
for r in common.NOTIFY_MODULE_MAP.values() results = plugin.parse_native_url(_url)
if r['plugin'].parse_native_url(_url) is not None), if results:
None) break
if not results: if not results:
logger.error('Unparseable URL {}'.format(loggable_url)) logger.error('Unparseable URL {}'.format(loggable_url))
@ -560,14 +469,14 @@ def url_to_dict(url, secure_logging=True):
else: else:
# Parse our url details of the server object as dictionary # Parse our url details of the server object as dictionary
# containing all of the information parsed from our URL # containing all of the information parsed from our URL
results = common.NOTIFY_SCHEMA_MAP[schema].parse_url(_url) results = N_MGR[schema].parse_url(_url)
if not results: if not results:
logger.error('Unparseable {} URL {}'.format( logger.error('Unparseable {} URL {}'.format(
common.NOTIFY_SCHEMA_MAP[schema].service_name, loggable_url)) N_MGR[schema].service_name, loggable_url))
return None return None
logger.trace('{} URL {} unpacked as:{}{}'.format( logger.trace('{} URL {} unpacked as:{}{}'.format(
common.NOTIFY_SCHEMA_MAP[schema].service_name, url, N_MGR[schema].service_name, url,
os.linesep, os.linesep.join( os.linesep, os.linesep.join(
['{}="{}"'.format(k, v) for k, v in results.items()]))) ['{}="{}"'.format(k, v) for k, v in results.items()])))

View File

@ -31,14 +31,12 @@ import sys
import json import json
import contextlib import contextlib
import os import os
import hashlib
import locale import locale
from itertools import chain from itertools import chain
from os.path import expanduser from os.path import expanduser
from functools import reduce from functools import reduce
from . import common from . import common
from .logger import logger from .logger import logger
from urllib.parse import unquote from urllib.parse import unquote
from urllib.parse import quote from urllib.parse import quote
from urllib.parse import urlparse from urllib.parse import urlparse
@ -70,16 +68,12 @@ def import_module(path, name):
module = None module = None
logger.debug( logger.debug(
'Custom module exception raised from %s (name=%s) %s', 'Module exception raised from %s (name=%s) %s',
path, name, str(e)) path, name, str(e))
return module 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() # URL Indexing Table for returns via parse_url()
# The below accepts and scans for: # The below accepts and scans for:
# - schema:// # - schema://
@ -214,6 +208,22 @@ VALID_PYTHON_FILE_RE = re.compile(r'.+\.py(o|c)?$', re.IGNORECASE)
REGEX_VALIDATE_LOOKUP = {} REGEX_VALIDATE_LOOKUP = {}
class Singleton(type):
"""
Our Singleton MetaClass
"""
_instances = {}
def __call__(cls, *args, **kwargs):
"""
instantiate our singleton meta entry
"""
if cls not in cls._instances:
# we have not every built an instance before. Build one now.
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class TemplateType: class TemplateType:
""" """
Defines the different template types we can perform parsing on Defines the different template types we can perform parsing on
@ -1561,142 +1571,6 @@ def remove_suffix(value, suffix):
return value[:-len(suffix)] if value.endswith(suffix) else value 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, str):
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
if not (path and VALID_PYTHON_FILE_RE.match(path)):
# Ignore file/module type
logger.trace('Plugin Scan: Skipping %s', path)
return None
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): def dict_full_update(dict1, dict2):
""" """
Takes 2 dictionaries (dict1 and dict2) that contain sub-dictionaries and Takes 2 dictionaries (dict1 and dict2) that contain sub-dictionaries and

View File

@ -1,12 +1,15 @@
diff -Naur apprise-1.2.0/test/test_plugin_macosx.py apprise-1.2.0.patched/test/test_plugin_macosx.py diff -Naur apprise-1.6.0/test/test_plugin_macosx.py apprise-1.6.0.patched/test/test_plugin_macosx.py
--- apprise-1.2.0/test/test_plugin_macosx.py 2022-11-02 14:59:59.000000000 -0400 --- apprise-1.6.0/test/test_plugin_macosx.py 2023-12-22 16:51:24.000000000 -0500
+++ apprise-1.2.0.patched/test/test_plugin_macosx.py 2022-11-15 13:25:27.991284405 -0500 +++ apprise-1.6.0.patched/test/test_plugin_macosx.py 2023-12-22 17:38:35.720131819 -0500
@@ -38,7 +38,7 @@ @@ -42,9 +42,8 @@
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
-if sys.platform not in ["darwin", "linux"]: -if sys.platform not in ["darwin", "linux"]:
+if sys.platform not in ["darwin"]: - pytest.skip("Only makes sense on macOS, but testable in Linux",
pytest.skip("Only makes sense on macOS, but also works on Linux", - allow_module_level=True)
allow_module_level=True) +if sys.platform != "darwin":
+ pytest.skip("MacOS test only", allow_module_level=True)
@pytest.fixture

View File

@ -1,19 +1,20 @@
diff -Naur apprise-1.2.0/test/conftest.py apprise-1.2.0.patched/test/conftest.py diff -Naur apprise-1.6.0/test/conftest.py apprise-1.6.0-patched/test/conftest.py
--- apprise-1.2.0/test/conftest.py 2022-11-02 14:00:13.000000000 -0400 --- apprise-1.6.0/test/conftest.py 2023-12-27 11:20:40.000000000 -0500
+++ apprise-1.2.0.patched/test/conftest.py 2022-11-15 10:28:23.793969418 -0500 +++ apprise-1.6.0-patched/test/conftest.py 2023-12-27 13:43:22.583100037 -0500
@@ -32,12 +32,12 @@ @@ -45,8 +45,8 @@
sys.path.append(os.path.join(os.path.dirname(__file__), 'helpers')) A_MGR = AttachmentManager()
-@pytest.fixture(scope="session", autouse=True) -@pytest.fixture(scope="function", autouse=True)
-def no_throttling_everywhere(session_mocker): -def no_throttling_everywhere(session_mocker):
+@pytest.fixture(autouse=True) +@pytest.fixture(autouse=True)
+def no_throttling_everywhere(mocker): +def no_throttling_everywhere(mocker):
""" """
A pytest session fixture which disables throttling on all notifiers. A pytest session fixture which disables throttling on all notifiers.
It is automatically enabled. It is automatically enabled.
""" @@ -57,4 +57,4 @@
for notifier in NOTIFY_MODULE_MAP.values(): A_MGR.unload_modules()
plugin = notifier["plugin"]
for plugin in N_MGR.plugins():
- session_mocker.patch.object(plugin, "request_rate_per_sec", 0) - session_mocker.patch.object(plugin, "request_rate_per_sec", 0)
+ mocker.patch.object(plugin, "request_rate_per_sec", 0) + mocker.patch.object(plugin, "request_rate_per_sec", 0)

View File

@ -31,17 +31,30 @@ import os
import pytest import pytest
from apprise.common import NOTIFY_MODULE_MAP from apprise.NotificationManager import NotificationManager
from apprise.ConfigurationManager import ConfigurationManager
from apprise.AttachmentManager import AttachmentManager
sys.path.append(os.path.join(os.path.dirname(__file__), 'helpers')) sys.path.append(os.path.join(os.path.dirname(__file__), 'helpers'))
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
# Grant access to our Config Manager Singleton
C_MGR = ConfigurationManager()
# Grant access to our Attachment Manager Singleton
A_MGR = AttachmentManager()
@pytest.fixture(scope="session", autouse=True)
@pytest.fixture(scope="function", autouse=True)
def no_throttling_everywhere(session_mocker): def no_throttling_everywhere(session_mocker):
""" """
A pytest session fixture which disables throttling on all notifiers. A pytest session fixture which disables throttling on all notifiers.
It is automatically enabled. It is automatically enabled.
""" """
for notifier in NOTIFY_MODULE_MAP.values(): # Ensure we're working with a clean slate for each test
plugin = notifier["plugin"] N_MGR.unload_modules()
C_MGR.unload_modules()
A_MGR.unload_modules()
for plugin in N_MGR.plugins():
session_mocker.patch.object(plugin, "request_rate_per_sec", 0) session_mocker.patch.object(plugin, "request_rate_per_sec", 0)

View File

@ -26,11 +26,21 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
from itertools import chain
from importlib import import_module, reload from importlib import import_module, reload
from apprise.NotificationManager import NotificationManager
from apprise.AttachmentManager import AttachmentManager
import sys import sys
import re
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
# Grant access to our Attachment Manager Singleton
A_MGR = AttachmentManager()
def reload_plugin(name, replace_in=None): def reload_plugin(name):
""" """
Reload built-in plugin module, e.g. `NotifyGnome`. Reload built-in plugin module, e.g. `NotifyGnome`.
@ -40,21 +50,72 @@ def reload_plugin(name, replace_in=None):
See also https://stackoverflow.com/questions/31363311. See also https://stackoverflow.com/questions/31363311.
""" """
module_name = f"apprise.plugins.{name}" A_MGR.unload_modules()
reload(sys.modules['apprise.common']) reload(sys.modules['apprise.attachment.AttachBase'])
reload(sys.modules['apprise.attachment']) reload(sys.modules['apprise.AppriseAttachment'])
reload(sys.modules['apprise.config']) new_apprise_attachment_mod = import_module('apprise.AppriseAttachment')
if module_name in sys.modules: reload(sys.modules['apprise.AttachmentManager'])
reload(sys.modules[module_name])
reload(sys.modules['apprise.plugins']) module_pyname = '{}.{}'.format(N_MGR.module_name_prefix, name)
if module_pyname in sys.modules:
reload(sys.modules[module_pyname])
new_notify_mod = import_module(module_pyname)
reload(sys.modules['apprise.NotificationManager'])
reload(sys.modules['apprise.Apprise']) reload(sys.modules['apprise.Apprise'])
reload(sys.modules['apprise.utils']) reload(sys.modules['apprise.utils'])
reload(sys.modules['apprise']) reload(sys.modules['apprise'])
# Fix reference to new plugin class in given module. # Filter our keys
# Needed for updating the module-level import reference like tests = [k for k in sys.modules.keys() if re.match(r'^test_.+$', k)]
# `from apprise.plugins.NotifyMacOSX import NotifyMacOSX`. for module_name in tests:
if replace_in is not None: possible_matches = \
mod = import_module(module_name) [m for m in dir(sys.modules[module_name])
setattr(replace_in, name, getattr(mod, name)) if re.match(r'^(?P<name>Notify[a-z0-9]+)$', m, re.I)]
if not possible_matches:
continue
# Fix reference to new plugin class in given module.
# Needed for updating the module-level import reference like
# `from apprise.plugins.NotifyABCDE import NotifyABCDE`.
#
# We reload NotifyABCDE and place it back in its spot
test_mod = import_module(module_name)
setattr(test_mod, name, getattr(new_notify_mod, name))
# Detect our Apprise Modules (include helpers)
apprise_modules = \
[k for k in sys.modules.keys()
if re.match(r'^(apprise|helpers)(\.|.+)$', k)]
for entry in A_MGR:
reload(sys.modules[entry['path']])
for module_pyname in chain(apprise_modules, tests):
detect = re.compile(
r'^(?P<name>(AppriseAttachment|AttachBase|' +
entry['path'].split('.')[-1] + r'))$')
possible_matches = \
[m for m in dir(sys.modules[module_pyname]) if detect.match(m)]
if not possible_matches:
continue
apprise_mod = import_module(module_pyname)
# Fix reference to new plugin class in given module.
# Needed for updating the module-level import reference
# like `from apprise.<etc> import NotifyABCDE`.
#
# We reload NotifyABCDE and place it back in its spot
# new_attach = import_module(entry['path'])
for name in possible_matches:
if name == 'AppriseAttachment':
setattr(
apprise_mod, name,
getattr(new_apprise_attachment_mod, name))
else:
module_pyname = '{}.{}'.format(
A_MGR.module_name_prefix, name)
new_attach_mod = import_module(module_pyname)
setattr(apprise_mod, name, getattr(new_attach_mod, name))

View File

@ -32,6 +32,7 @@ import re
import sys import sys
import pytest import pytest
import requests import requests
from inspect import cleandoc
from unittest import mock from unittest import mock
from os.path import dirname from os.path import dirname
@ -50,9 +51,7 @@ from apprise import PrivacyMode
from apprise.AppriseLocale import LazyTranslation from apprise.AppriseLocale import LazyTranslation
from apprise.AppriseLocale import gettext_lazy as _ from apprise.AppriseLocale import gettext_lazy as _
from apprise import common from apprise.NotificationManager import NotificationManager
from apprise.plugins import __load_matrix
from apprise.plugins import __reset_matrix
from apprise.utils import parse_list from apprise.utils import parse_list
from helpers import OuterEventLoop from helpers import OuterEventLoop
import inspect import inspect
@ -64,8 +63,11 @@ logging.disable(logging.CRITICAL)
# Attachment Directory # Attachment Directory
TEST_VAR_DIR = join(dirname(__file__), 'var') TEST_VAR_DIR = join(dirname(__file__), 'var')
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
def test_apprise():
def test_apprise_object():
""" """
API: Apprise() object API: Apprise() object
@ -90,11 +92,6 @@ def test_apprise_async():
def apprise_test(do_notify): def apprise_test(do_notify):
# Caling load matix a second time which is an internal function causes it
# to skip over content already loaded into our matrix and thefore accesses
# other if/else parts of the code that aren't otherwise called
__load_matrix()
a = Apprise() a = Apprise()
# no items # no items
@ -217,10 +214,10 @@ def apprise_test(do_notify):
return NotifyBase.parse_url(url, verify_host=False) return NotifyBase.parse_url(url, verify_host=False)
# Store our bad notification in our schema map # Store our bad notification in our schema map
common.NOTIFY_SCHEMA_MAP['bad'] = BadNotification N_MGR['bad'] = BadNotification
# Store our good notification in our schema map # Store our good notification in our schema map
common.NOTIFY_SCHEMA_MAP['good'] = GoodNotification N_MGR['good'] = GoodNotification
# Just to explain what is happening here, we would have parsed the # 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 # url properly but failed when we went to go and create an instance
@ -334,13 +331,13 @@ def apprise_test(do_notify):
return '' return ''
# Store our bad notification in our schema map # Store our bad notification in our schema map
common.NOTIFY_SCHEMA_MAP['throw'] = ThrowNotification N_MGR['throw'] = ThrowNotification
# Store our good notification in our schema map # Store our good notification in our schema map
common.NOTIFY_SCHEMA_MAP['fail'] = FailNotification N_MGR['fail'] = FailNotification
# Store our good notification in our schema map # Store our good notification in our schema map
common.NOTIFY_SCHEMA_MAP['runtime'] = RuntimeNotification N_MGR['runtime'] = RuntimeNotification
for async_mode in (True, False): for async_mode in (True, False):
# Create an Asset object # Create an Asset object
@ -368,7 +365,11 @@ def apprise_test(do_notify):
# Support URL # Support URL
return '' return ''
common.NOTIFY_SCHEMA_MAP['throw'] = ThrowInstantiateNotification N_MGR.unload_modules()
N_MGR['throw'] = ThrowInstantiateNotification
# Store our good notification in our schema map
N_MGR['good'] = GoodNotification
# Reset our object # Reset our object
a.clear() a.clear()
@ -680,11 +681,7 @@ def test_apprise_schemas(tmpdir):
API: Apprise().schema() tests API: Apprise().schema() tests
""" """
# Caling load matix a second time which is an internal function causes it # Clear loaded modules
# to skip over content already loaded into our matrix and thefore accesses
# other if/else parts of the code that aren't otherwise called
__load_matrix()
a = Apprise() a = Apprise()
# no items # no items
@ -711,16 +708,23 @@ def test_apprise_schemas(tmpdir):
secure_protocol = 'markdowns' secure_protocol = 'markdowns'
# Store our notifications into our schema map
common.NOTIFY_SCHEMA_MAP['text'] = TextNotification
common.NOTIFY_SCHEMA_MAP['html'] = HtmlNotification
common.NOTIFY_SCHEMA_MAP['markdown'] = MarkDownNotification
schemas = URLBase.schemas(TextNotification) schemas = URLBase.schemas(TextNotification)
assert isinstance(schemas, set) is True assert isinstance(schemas, set) is True
# We didn't define a protocol or secure protocol # We didn't define a protocol or secure protocol
assert len(schemas) == 0 assert len(schemas) == 0
# Store our notifications into our schema map
N_MGR['text'] = TextNotification
N_MGR['html'] = HtmlNotification
N_MGR['markdown'] = MarkDownNotification
schemas = URLBase.schemas(TextNotification)
assert isinstance(schemas, set) is True
# We didn't define a protocol or secure protocol one
# but one got assigned in he above N_MGR call
assert len(schemas) == 1
assert 'text' in schemas
schemas = URLBase.schemas(HtmlNotification) schemas = URLBase.schemas(HtmlNotification)
assert isinstance(schemas, set) is True assert isinstance(schemas, set) is True
assert len(schemas) == 4 assert len(schemas) == 4
@ -784,11 +788,6 @@ def test_apprise_notify_formats(tmpdir):
API: Apprise() Input Formats tests API: Apprise() Input Formats tests
""" """
# Caling load matix a second time which is an internal function causes it
# to skip over content already loaded into our matrix and thefore accesses
# other if/else parts of the code that aren't otherwise called
__load_matrix()
# Need to set async_mode=False to call notify() instead of async_notify(). # Need to set async_mode=False to call notify() instead of async_notify().
asset = AppriseAsset(async_mode=False) asset = AppriseAsset(async_mode=False)
@ -845,9 +844,9 @@ def test_apprise_notify_formats(tmpdir):
return '' return ''
# Store our notifications into our schema map # Store our notifications into our schema map
common.NOTIFY_SCHEMA_MAP['text'] = TextNotification N_MGR['text'] = TextNotification
common.NOTIFY_SCHEMA_MAP['html'] = HtmlNotification N_MGR['html'] = HtmlNotification
common.NOTIFY_SCHEMA_MAP['markdown'] = MarkDownNotification N_MGR['markdown'] = MarkDownNotification
# Test Markdown; the above calls the markdown because our good:// # Test Markdown; the above calls the markdown because our good://
# defined plugin above was defined to default to HTML which triggers # defined plugin above was defined to default to HTML which triggers
@ -1021,8 +1020,9 @@ def test_apprise_disabled_plugins():
API: Apprise() Disabled Plugin States API: Apprise() Disabled Plugin States
""" """
# Reset our matrix # Ensure there are no other drives loaded
__reset_matrix() N_MGR.unload_modules(disable_native=True)
assert len(N_MGR) == 0
class TestDisabled01Notification(NotifyBase): class TestDisabled01Notification(NotifyBase):
""" """
@ -1044,7 +1044,7 @@ def test_apprise_disabled_plugins():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['na01'] = TestDisabled01Notification N_MGR['na01'] = TestDisabled01Notification
class TestDisabled02Notification(NotifyBase): class TestDisabled02Notification(NotifyBase):
""" """
@ -1069,7 +1069,7 @@ def test_apprise_disabled_plugins():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['na02'] = TestDisabled02Notification N_MGR['na02'] = TestDisabled02Notification
# Create our Apprise instance # Create our Apprise instance
a = Apprise() a = Apprise()
@ -1127,7 +1127,7 @@ def test_apprise_disabled_plugins():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['good'] = TesEnabled01Notification N_MGR['good'] = TesEnabled01Notification
# The last thing we'll simulate is a case where the plugin is just # 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 # disabled at a later time long into it's life. this is just to allow
@ -1148,9 +1148,8 @@ def test_apprise_disabled_plugins():
# our notifications will go okay now # our notifications will go okay now
assert plugin.notify("My Message") is True assert plugin.notify("My Message") is True
# Reset our matrix # Restore our modules
__reset_matrix() N_MGR.unload_modules()
__load_matrix()
def test_apprise_details(): def test_apprise_details():
@ -1158,9 +1157,6 @@ def test_apprise_details():
API: Apprise() Details API: Apprise() Details
""" """
# Reset our matrix
__reset_matrix()
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestDetailNotification(NotifyBase): class TestDetailNotification(NotifyBase):
""" """
@ -1190,22 +1186,23 @@ def test_apprise_details():
# previously defined in the base package and builds onto them # previously defined in the base package and builds onto them
template_tokens = dict(NotifyBase.template_tokens, **{ template_tokens = dict(NotifyBase.template_tokens, **{
'notype': { 'notype': {
# Nothing defined is still valid # name is a minimum requirement
'name': _('no type'),
}, },
'regex_test01': { 'regex_test01': {
'name': _('RegexTest'), 'name': _('RegexTest'),
'type': 'string', 'type': 'string',
'regex': r'[A-Z0-9]', 'regex': r'^[A-Z0-9]$',
}, },
'regex_test02': { 'regex_test02': {
'name': _('RegexTest'), 'name': _('RegexTest'),
# Support regex options too # Support regex options too
'regex': (r'[A-Z0-9]', 'i'), 'regex': (r'^[A-Z0-9]$', 'i'),
}, },
'regex_test03': { 'regex_test03': {
'name': _('RegexTest'), 'name': _('RegexTest'),
# Support regex option without a second option # Support regex option without a second option
'regex': (r'[A-Z0-9]'), 'regex': (r'^[A-Z0-9]$'),
}, },
'regex_test04': { 'regex_test04': {
# this entry would just end up getting removed # this entry would just end up getting removed
@ -1267,7 +1264,7 @@ def test_apprise_details():
return True return True
# Store our good detail notification in our schema map # Store our good detail notification in our schema map
common.NOTIFY_SCHEMA_MAP['details'] = TestDetailNotification N_MGR['details'] = TestDetailNotification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq01Notification(NotifyBase): class TestReq01Notification(NotifyBase):
@ -1292,7 +1289,7 @@ def test_apprise_details():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['req01'] = TestReq01Notification N_MGR['req01'] = TestReq01Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq02Notification(NotifyBase): class TestReq02Notification(NotifyBase):
@ -1322,7 +1319,7 @@ def test_apprise_details():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['req02'] = TestReq02Notification N_MGR['req02'] = TestReq02Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq03Notification(NotifyBase): class TestReq03Notification(NotifyBase):
@ -1348,7 +1345,7 @@ def test_apprise_details():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['req03'] = TestReq03Notification N_MGR['req03'] = TestReq03Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq04Notification(NotifyBase): class TestReq04Notification(NotifyBase):
@ -1368,7 +1365,7 @@ def test_apprise_details():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['req04'] = TestReq04Notification N_MGR['req04'] = TestReq04Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq05Notification(NotifyBase): class TestReq05Notification(NotifyBase):
@ -1390,7 +1387,7 @@ def test_apprise_details():
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
common.NOTIFY_SCHEMA_MAP['req05'] = TestReq05Notification N_MGR['req05'] = TestReq05Notification
# Create our Apprise instance # Create our Apprise instance
a = Apprise() a = Apprise()
@ -1451,21 +1448,13 @@ def test_apprise_details():
assert isinstance(entry['requirements']['packages_required'], list) assert isinstance(entry['requirements']['packages_required'], list)
assert isinstance(entry['requirements']['packages_recommended'], list) assert isinstance(entry['requirements']['packages_recommended'], list)
# Reset our matrix
__reset_matrix()
__load_matrix()
def test_apprise_details_plugin_verification(): def test_apprise_details_plugin_verification():
""" """
API: Apprise() Details Plugin Verification API: Apprise() Details Plugin Verification
""" """
# Prepare our object
# Reset our matrix
__reset_matrix()
__load_matrix()
a = Apprise() a = Apprise()
# Details object # Details object
@ -1757,7 +1746,7 @@ def test_apprise_details_plugin_verification():
) )
spec = inspect.getfullargspec( spec = inspect.getfullargspec(
common.NOTIFY_SCHEMA_MAP[protocols[0]].__init__) N_MGR._schema_map[protocols[0]].__init__)
function_args = \ function_args = \
(set(parse_list(spec.varkw)) - set(['kwargs'])) \ (set(parse_list(spec.varkw)) - set(['kwargs'])) \
@ -1773,7 +1762,8 @@ def test_apprise_details_plugin_verification():
'{}.__init__() expects a {}=None entry according to ' '{}.__init__() expects a {}=None entry according to '
'template configuration' 'template configuration'
.format( .format(
common.NOTIFY_SCHEMA_MAP[protocols[0]].__name__, arg)) N_MGR._schema_map
[protocols[0]].__name__, arg))
# Iterate over all of the function arguments and make sure that # Iterate over all of the function arguments and make sure that
# it maps back to a key # it maps back to a key
@ -1783,8 +1773,7 @@ def test_apprise_details_plugin_verification():
raise AssertionError( raise AssertionError(
'{}.__init__({}) found but not defined in the ' '{}.__init__({}) found but not defined in the '
'template configuration' 'template configuration'
.format( .format(N_MGR._schema_map[protocols[0]].__name__, arg))
common.NOTIFY_SCHEMA_MAP[protocols[0]].__name__, arg))
# Iterate over our map_to_aliases and make sure they were defined in # Iterate over our map_to_aliases and make sure they were defined in
# either the as a token or arg # either the as a token or arg
@ -1935,56 +1924,61 @@ def test_notify_matrix_dynamic_importing(tmpdir):
base.join("__init__.py").write('') base.join("__init__.py").write('')
# Test no app_id # Test no app_id
base.join('NotifyBadFile1.py').write( base.join('NotifyBadFile1.py').write(cleandoc(
""" """
class NotifyBadFile1: class NotifyBadFile1:
pass""") pass
"""))
# No class of the same name # No class of the same name
base.join('NotifyBadFile2.py').write( base.join('NotifyBadFile2.py').write(cleandoc(
""" """
class BadClassName: class BadClassName:
pass""") pass
"""))
# Exception thrown # Exception thrown
base.join('NotifyBadFile3.py').write("""raise ImportError()""") base.join('NotifyBadFile3.py').write("""raise ImportError()""")
# Utilizes a schema:// already occupied (as string) # Utilizes a schema:// already occupied (as string)
base.join('NotifyGoober.py').write( base.join('NotifyGoober.py').write(cleandoc(
""" """
from apprise import NotifyBase from apprise import NotifyBase
class NotifyGoober(NotifyBase): class NotifyGoober(NotifyBase):
# This class tests the fact we have a new class name, but we're # This class tests the fact we have a new class name, but we're
# trying to over-ride items previously used # trying to over-ride items previously used
# The default simple (insecure) protocol (used by NotifyMail) # The default simple (insecure) protocol (used by NotifyMail)
protocol = ('mailto', 'goober') protocol = ('mailto', 'goober')
# The default secure protocol (used by NotifyMail) # The default secure protocol (used by NotifyMail)
secure_protocol = 'mailtos' secure_protocol = 'mailtos'
@staticmethod @staticmethod
def parse_url(url, *args, **kwargs): def parse_url(url, *args, **kwargs):
# always parseable # always parseable
return ConfigBase.parse_url(url, verify_host=False)""") return ConfigBase.parse_url(url, verify_host=False)
"""))
# Utilizes a schema:// already occupied (as tuple) # Utilizes a schema:// already occupied (as tuple)
base.join('NotifyBugger.py').write(""" base.join('NotifyBugger.py').write(cleandoc(
from apprise import NotifyBase """
class NotifyBugger(NotifyBase): from apprise import NotifyBase
# This class tests the fact we have a new class name, but we're class NotifyBugger(NotifyBase):
# trying to over-ride items previously used # This class tests the fact we have a new class name, but we're
# trying to over-ride items previously used
# The default simple (insecure) protocol (used by NotifyMail), the other # The default simple (insecure) protocol (used by NotifyMail), the
# isn't # other isn't
protocol = ('mailto', 'bugger-test' ) protocol = ('mailto', 'bugger-test' )
# The default secure protocol (used by NotifyMail), the other isn't # The default secure protocol (used by NotifyMail), the other isn't
secure_protocol = ('mailtos', ['garbage']) secure_protocol = ('mailtos', ['garbage'])
@staticmethod @staticmethod
def parse_url(url, *args, **kwargs): def parse_url(url, *args, **kwargs):
# always parseable # always parseable
return ConfigBase.parse_url(url, verify_host=False)""") return ConfigBase.parse_url(url, verify_host=False)
"""))
__load_matrix(path=str(base), name=module_name) N_MGR.load_modules(path=str(base), name=module_name)

View File

@ -26,16 +26,15 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
import sys
import pytest import pytest
from os.path import getsize from os.path import getsize
from os.path import join from os.path import join
from os.path import dirname from os.path import dirname
from inspect import cleandoc
from apprise.AttachmentManager import AttachmentManager
from apprise.AppriseAttachment import AppriseAttachment from apprise.AppriseAttachment import AppriseAttachment
from apprise.AppriseAsset import AppriseAsset from apprise.AppriseAsset import AppriseAsset
from apprise.attachment.AttachBase import AttachBase from apprise.attachment.AttachBase import AttachBase
from apprise.common import ATTACHMENT_SCHEMA_MAP
from apprise.attachment import __load_matrix
from apprise.common import ContentLocation from apprise.common import ContentLocation
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
@ -44,6 +43,9 @@ logging.disable(logging.CRITICAL)
TEST_VAR_DIR = join(dirname(__file__), 'var') TEST_VAR_DIR = join(dirname(__file__), 'var')
# Grant access to our Attachment Manager Singleton
A_MGR = AttachmentManager()
def test_apprise_attachment(): def test_apprise_attachment():
""" """
@ -278,7 +280,7 @@ def test_apprise_attachment_instantiate():
raise TypeError() raise TypeError()
# Store our bad attachment type in our schema map # Store our bad attachment type in our schema map
ATTACHMENT_SCHEMA_MAP['bad'] = BadAttachType A_MGR['bad'] = BadAttachType
with pytest.raises(TypeError): with pytest.raises(TypeError):
AppriseAttachment.instantiate( AppriseAttachment.instantiate(
@ -289,77 +291,6 @@ def test_apprise_attachment_instantiate():
'bad://path', suppress_exceptions=True) is None 'bad://path', suppress_exceptions=True) is None
def test_apprise_attachment_matrix_load():
"""
API: AppriseAttachment() matrix initialization
"""
import apprise
class AttachmentDummy(AttachBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy'
# protocol as tuple
protocol = ('uh', 'oh')
# secure protocol as tuple
secure_protocol = ('no', 'yes')
class AttachmentDummy2(AttachBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy2'
# secure protocol as tuple
secure_protocol = ('true', 'false')
class AttachmentDummy3(AttachBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy3'
# secure protocol as string
secure_protocol = 'true'
class AttachmentDummy4(AttachBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy4'
# protocol as string
protocol = 'true'
# Generate ourselves a fake entry
apprise.attachment.AttachmentDummy = AttachmentDummy
apprise.attachment.AttachmentDummy2 = AttachmentDummy2
apprise.attachment.AttachmentDummy3 = AttachmentDummy3
apprise.attachment.AttachmentDummy4 = AttachmentDummy4
__load_matrix()
# Call it again so we detect our entries already loaded
__load_matrix()
def test_attachment_matrix_dynamic_importing(tmpdir): def test_attachment_matrix_dynamic_importing(tmpdir):
""" """
API: Apprise() Attachment Matrix Importing API: Apprise() Attachment Matrix Importing
@ -372,53 +303,55 @@ def test_attachment_matrix_dynamic_importing(tmpdir):
module_name = 'badattach' module_name = 'badattach'
# Update our path to point to our new test suite
sys.path.insert(0, str(suite))
# Create a base area to work within # Create a base area to work within
base = suite.mkdir(module_name) base = suite.mkdir(module_name)
base.join("__init__.py").write('') base.join("__init__.py").write('')
# Test no app_id # Test no app_id
base.join('AttachBadFile1.py').write( base.join('AttachBadFile1.py').write(cleandoc(
""" """
class AttachBadFile1: class AttachBadFile1:
pass""") pass
"""))
# No class of the same name # No class of the same name
base.join('AttachBadFile2.py').write( base.join('AttachBadFile2.py').write(cleandoc(
""" """
class BadClassName: class BadClassName:
pass""") pass
"""))
# Exception thrown # Exception thrown
base.join('AttachBadFile3.py').write("""raise ImportError()""") base.join('AttachBadFile3.py').write("""raise ImportError()""")
# Utilizes a schema:// already occupied (as string) # Utilizes a schema:// already occupied (as string)
base.join('AttachGoober.py').write( base.join('AttachGoober.py').write(cleandoc(
""" """
from apprise import AttachBase from apprise import AttachBase
class AttachGoober(AttachBase): class AttachGoober(AttachBase):
# This class tests the fact we have a new class name, but we're # This class tests the fact we have a new class name, but we're
# trying to over-ride items previously used # trying to over-ride items previously used
# The default simple (insecure) protocol # The default simple (insecure) protocol
protocol = 'http' protocol = 'http'
# The default secure protocol # The default secure protocol
secure_protocol = 'https'""") secure_protocol = 'https'
"""))
# Utilizes a schema:// already occupied (as tuple) # Utilizes a schema:// already occupied (as tuple)
base.join('AttachBugger.py').write(""" base.join('AttachBugger.py').write(cleandoc(
from apprise import AttachBase """
class AttachBugger(AttachBase): from apprise import AttachBase
# This class tests the fact we have a new class name, but we're class AttachBugger(AttachBase):
# trying to over-ride items previously used # This class tests the fact we have a new class name, but we're
# trying to over-ride items previously used
# The default simple (insecure) protocol # The default simple (insecure) protocol
protocol = ('http', 'bugger-test' ) protocol = ('http', 'bugger-test' )
# The default secure protocol # The default secure protocol
secure_protocol = ('https', 'bugger-tests')""") secure_protocol = ('https', 'bugger-tests')
"""))
__load_matrix(path=str(base), name=module_name) A_MGR.load_modules(path=str(base), name=module_name)

View File

@ -36,23 +36,21 @@ from os.path import dirname
from os.path import join from os.path import join
from apprise import cli from apprise import cli
from apprise import NotifyBase from apprise import NotifyBase
from apprise.common import NOTIFY_CUSTOM_MODULE_MAP from apprise.NotificationManager import NotificationManager
from apprise.utils import PATHS_PREVIOUSLY_SCANNED
from click.testing import CliRunner from click.testing import CliRunner
from apprise.common import NOTIFY_SCHEMA_MAP
from apprise.utils import environ from apprise.utils import environ
from apprise.plugins import __load_matrix
from apprise.plugins import __reset_matrix
from apprise.AppriseLocale import gettext_lazy as _ from apprise.AppriseLocale import gettext_lazy as _
from importlib import reload from importlib import reload
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
def test_apprise_cli_nux_env(tmpdir): def test_apprise_cli_nux_env(tmpdir):
""" """
@ -89,8 +87,8 @@ def test_apprise_cli_nux_env(tmpdir):
return 'bad://' return 'bad://'
# Set up our notification types # Set up our notification types
NOTIFY_SCHEMA_MAP['good'] = GoodNotification N_MGR['good'] = GoodNotification
NOTIFY_SCHEMA_MAP['bad'] = BadNotification N_MGR['bad'] = BadNotification
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli.main) result = runner.invoke(cli.main)
@ -639,8 +637,8 @@ def test_apprise_cli_details(tmpdir):
]) ])
assert result.exit_code == 0 assert result.exit_code == 0
# Reset our matrix # Clear loaded modules
__reset_matrix() N_MGR.unload_modules()
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq01Notification(NotifyBase): class TestReq01Notification(NotifyBase):
@ -665,7 +663,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['req01'] = TestReq01Notification N_MGR['req01'] = TestReq01Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq02Notification(NotifyBase): class TestReq02Notification(NotifyBase):
@ -695,7 +693,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['req02'] = TestReq02Notification N_MGR['req02'] = TestReq02Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq03Notification(NotifyBase): class TestReq03Notification(NotifyBase):
@ -721,7 +719,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['req03'] = TestReq03Notification N_MGR['req03'] = TestReq03Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq04Notification(NotifyBase): class TestReq04Notification(NotifyBase):
@ -741,7 +739,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['req04'] = TestReq04Notification N_MGR['req04'] = TestReq04Notification
# This is a made up class that is just used to verify # This is a made up class that is just used to verify
class TestReq05Notification(NotifyBase): class TestReq05Notification(NotifyBase):
@ -762,7 +760,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['req05'] = TestReq05Notification N_MGR['req05'] = TestReq05Notification
class TestDisabled01Notification(NotifyBase): class TestDisabled01Notification(NotifyBase):
""" """
@ -784,7 +782,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['na01'] = TestDisabled01Notification N_MGR['na01'] = TestDisabled01Notification
class TestDisabled02Notification(NotifyBase): class TestDisabled02Notification(NotifyBase):
""" """
@ -809,7 +807,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['na02'] = TestDisabled02Notification N_MGR['na02'] = TestDisabled02Notification
# We'll add a good notification to our list # We'll add a good notification to our list
class TesEnabled01Notification(NotifyBase): class TesEnabled01Notification(NotifyBase):
@ -829,7 +827,7 @@ def test_apprise_cli_details(tmpdir):
# Pretend everything is okay (so we don't break other tests) # Pretend everything is okay (so we don't break other tests)
return True return True
NOTIFY_SCHEMA_MAP['good'] = TesEnabled01Notification N_MGR['good'] = TesEnabled01Notification
# Verify that we can pass through all of our different details # Verify that we can pass through all of our different details
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
@ -842,9 +840,8 @@ def test_apprise_cli_details(tmpdir):
]) ])
assert result.exit_code == 0 assert result.exit_code == 0
# Reset our matrix # Clear loaded modules
__reset_matrix() N_MGR.unload_modules()
__load_matrix()
@mock.patch('requests.post') @mock.patch('requests.post')
@ -863,8 +860,8 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
# This simulates an actual call from the CLI. Unfortunately through # This simulates an actual call from the CLI. Unfortunately through
# testing were occupying the same memory space so our singleton's # testing were occupying the same memory space so our singleton's
# have already been populated # have already been populated
PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# Test a path that has no files to load in it # Test a path that has no files to load in it
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
@ -877,8 +874,8 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
assert result.exit_code == 0 assert result.exit_code == 0
# Directories that don't exist passed in by the CLI aren't even scanned # Directories that don't exist passed in by the CLI aren't even scanned
assert len(PATHS_PREVIOUSLY_SCANNED) == 0 assert len(N_MGR._paths_previously_scanned) == 0
assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Test our current existing path that has no entries in it # Test our current existing path that has no entries in it
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
@ -889,18 +886,19 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
# The path is silently loaded but fails... it's okay because the # The path is silently loaded but fails... it's okay because the
# notification we're choosing to notify does exist # notification we're choosing to notify does exist
assert result.exit_code == 0 assert result.exit_code == 0
assert len(PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert join(str(tmpdir), 'empty') in PATHS_PREVIOUSLY_SCANNED assert join(str(tmpdir), 'empty') in \
N_MGR._paths_previously_scanned
# However there was nothing to load # However there was nothing to load
assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Clear our working variables so they don't obstruct the next test # Clear our working variables so they don't obstruct the next test
# This simulates an actual call from the CLI. Unfortunately through # This simulates an actual call from the CLI. Unfortunately through
# testing were occupying the same memory space so our singleton's # testing were occupying the same memory space so our singleton's
# have already been populated # have already been populated
PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# Prepare ourselves a file to work with # Prepare ourselves a file to work with
notify_hook_a_base = tmpdir.mkdir('random') notify_hook_a_base = tmpdir.mkdir('random')
@ -921,10 +919,10 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
# The path is silently loaded but fails... it's okay because the # The path is silently loaded but fails... it's okay because the
# notification we're choosing to notify does exist # notification we're choosing to notify does exist
assert len(PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert str(notify_hook_a) in PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_a) in N_MGR._paths_previously_scanned
# However there was nothing to load # However there was nothing to load
assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Prepare ourselves a file to work with # Prepare ourselves a file to work with
notify_hook_aa = notify_hook_a_base.join('myhook02.py') notify_hook_aa = notify_hook_a_base.join('myhook02.py')
@ -932,11 +930,12 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
garbage entry garbage entry
""")) """))
N_MGR.plugins()
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
'--plugin-path', str(notify_hook_aa), '--plugin-path', str(notify_hook_aa),
'-b', 'test\nbody', '-b', 'test\nbody',
# A custom hook: # A custom hook:
'clihook://', 'clihook://custom',
]) ])
# It doesn't exist so it will fail # It doesn't exist so it will fail
# meanwhile we would have failed to load the myhook path # meanwhile we would have failed to load the myhook path
@ -944,18 +943,20 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
# The path is silently loaded but fails... # The path is silently loaded but fails...
# as a result the path stacks with the last # as a result the path stacks with the last
assert len(PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert str(notify_hook_a) in PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_a) in \
assert str(notify_hook_aa) in PATHS_PREVIOUSLY_SCANNED N_MGR._paths_previously_scanned
assert str(notify_hook_aa) in \
N_MGR._paths_previously_scanned
# However there was nothing to load # However there was nothing to load
assert len(NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Clear our working variables so they don't obstruct the next test # Clear our working variables so they don't obstruct the next test
# This simulates an actual call from the CLI. Unfortunately through # This simulates an actual call from the CLI. Unfortunately through
# testing were occupying the same memory space so our singleton's # testing were occupying the same memory space so our singleton's
# have already been populated # have already been populated
PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# Prepare ourselves a file to work with # Prepare ourselves a file to work with
notify_hook_b = tmpdir.mkdir('goodmodule').join('__init__.py') notify_hook_b = tmpdir.mkdir('goodmodule').join('__init__.py')
@ -968,6 +969,14 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
# A simple test - print to screen # A simple test - print to screen
print("{}: {} - {}".format(notify_type, title, body)) print("{}: {} - {}".format(notify_type, title, body))
# No return (so a return of None) get's translated to True
# Define another in the same file
@notify(on="clihookA")
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 # No return (so a return of None) get's translated to True
""")) """))
@ -975,7 +984,7 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
'--plugin-path', str(tmpdir), '--plugin-path', str(tmpdir),
'-b', 'test body', '-b', 'test body',
# A custom hook: # A custom hook:
'clihook://', 'clihook://still/valid',
]) ])
# We can detect the goodmodule (which has an __init__.py in it) # We can detect the goodmodule (which has an __init__.py in it)
@ -983,51 +992,52 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
assert result.exit_code == 0 assert result.exit_code == 0
# Let's see how things got loaded: # Let's see how things got loaded:
assert len(PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert str(tmpdir) in PATHS_PREVIOUSLY_SCANNED assert str(tmpdir) in N_MGR._paths_previously_scanned
# absolute path to detected module is also added # absolute path to detected module is also added
assert join(str(tmpdir), 'goodmodule', '__init__.py') \ assert join(str(tmpdir), 'goodmodule', '__init__.py') \
in PATHS_PREVIOUSLY_SCANNED in N_MGR._paths_previously_scanned
# We also loaded our clihook properly # We also loaded our clihook properly
assert len(NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# We can find our new hook loaded in our NOTIFY_SCHEMA_MAP now... # We can find our new hook loaded in our schema map now...
assert 'clihook' in NOTIFY_SCHEMA_MAP assert 'clihook' in N_MGR
# Capture our key for reference # Capture our key for reference
key = [k for k in NOTIFY_CUSTOM_MODULE_MAP.keys()][0] key = [k for k in N_MGR._custom_module_map.keys()][0]
assert len(NOTIFY_CUSTOM_MODULE_MAP[key]['notify']) == 1 # We loaded 2 entries from the same file
assert 'clihook' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] assert len(N_MGR._custom_module_map[key]['notify']) == 2
assert 'clihook' in N_MGR._custom_module_map[key]['notify']
# Converted to lower case
assert 'clihooka' in N_MGR._custom_module_map[key]['notify']
# Our function name # Our function name
assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['fn_name'] \ assert N_MGR._custom_module_map[key]['notify']['clihook']['fn_name'] \
== 'mywrapper' == 'mywrapper'
# What we parsed from the `on` keyword in the @notify decorator # What we parsed from the `on` keyword in the @notify decorator
assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['url'] \ assert N_MGR._custom_module_map[key]['notify']['clihook']['url'] \
== 'clihook://' == 'clihook://'
# our default name Assignment. This can be-overridden on the @notify # our default name Assignment. This can be-overridden on the @notify
# decorator by just adding a name= to the parameter list # decorator by just adding a name= to the parameter list
assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['name'] \ assert N_MGR['clihook'].service_name == 'Custom - clihook'
== 'Custom - clihook'
# Our Base Notification object when initialized: # Our Base Notification object when initialized:
assert isinstance( assert len(
NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['plugin'](), N_MGR._module_map[N_MGR._custom_module_map[key]['name']]['plugin']) \
NotifyBase) == 2
for plugin in \
# This is how it ties together in the backend N_MGR._module_map[N_MGR._custom_module_map[key]['name']]['plugin']:
assert NOTIFY_CUSTOM_MODULE_MAP[key]['notify']['clihook']['plugin'] == \ assert isinstance(plugin(), NotifyBase)
NOTIFY_SCHEMA_MAP['clihook']
# Clear our working variables so they don't obstruct the next test # Clear our working variables so they don't obstruct the next test
# This simulates an actual call from the CLI. Unfortunately through # This simulates an actual call from the CLI. Unfortunately through
# testing were occupying the same memory space so our singleton's # testing were occupying the same memory space so our singleton's
# have already been populated # have already been populated
PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
del NOTIFY_SCHEMA_MAP['clihook'] del N_MGR['clihook']
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
'--plugin-path', str(notify_hook_b), '--plugin-path', str(notify_hook_b),
@ -1046,9 +1056,9 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
# This simulates an actual call from the CLI. Unfortunately through # This simulates an actual call from the CLI. Unfortunately through
# testing were occupying the same memory space so our singleton's # testing were occupying the same memory space so our singleton's
# have already been populated # have already been populated
PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
del NOTIFY_SCHEMA_MAP['clihook'] del N_MGR['clihook']
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
'--plugin-path', dirname(str(notify_hook_b)), '--plugin-path', dirname(str(notify_hook_b)),
@ -1080,9 +1090,9 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
# This simulates an actual call from the CLI. Unfortunately through # This simulates an actual call from the CLI. Unfortunately through
# testing were occupying the same memory space so our singleton's # testing were occupying the same memory space so our singleton's
# have already been populated # have already been populated
PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
del NOTIFY_SCHEMA_MAP['clihook'] del N_MGR['clihook']
# Prepare ourselves a file to work with # Prepare ourselves a file to work with
notify_hook_b = tmpdir.mkdir('complex').join('complex.py') notify_hook_b = tmpdir.mkdir('complex').join('complex.py')
@ -1141,32 +1151,32 @@ def test_apprise_cli_plugin_loading(mock_post, tmpdir):
assert result.exit_code == 1 assert result.exit_code == 1
# Let's see how things got loaded # Let's see how things got loaded
assert len(PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
# Our path we specified on the CLI... # Our path we specified on the CLI...
assert join(str(tmpdir), 'complex') in PATHS_PREVIOUSLY_SCANNED assert join(str(tmpdir), 'complex') in N_MGR._paths_previously_scanned
# absolute path to detected module is also added # absolute path to detected module is also added
assert join(str(tmpdir), 'complex', 'complex.py') \ assert join(str(tmpdir), 'complex', 'complex.py') \
in PATHS_PREVIOUSLY_SCANNED in N_MGR._paths_previously_scanned
# We loaded our one module successfuly # We loaded our one module successfuly
assert len(NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# We can find our new hook loaded in our SCHEMA_MAP now... # We can find our new hook loaded in our SCHEMA_MAP now...
assert 'willfail' in NOTIFY_SCHEMA_MAP assert 'willfail' in N_MGR
assert 'clihook1' in NOTIFY_SCHEMA_MAP assert 'clihook1' in N_MGR
assert 'clihook2' in NOTIFY_SCHEMA_MAP assert 'clihook2' in N_MGR
# Capture our key for reference # Capture our key for reference
key = [k for k in NOTIFY_CUSTOM_MODULE_MAP.keys()][0] key = [k for k in N_MGR._custom_module_map.keys()][0]
assert len(NOTIFY_CUSTOM_MODULE_MAP[key]['notify']) == 3 assert len(N_MGR._custom_module_map[key]['notify']) == 3
assert 'willfail' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] assert 'willfail' in N_MGR._custom_module_map[key]['notify']
assert 'clihook1' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] assert 'clihook1' in N_MGR._custom_module_map[key]['notify']
# We only load 1 instance of the clihook2, the second will fail # We only load 1 instance of the clihook2, the second will fail
assert 'clihook2' in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] assert 'clihook2' in N_MGR._custom_module_map[key]['notify']
# We can never load previously created notifications # We can never load previously created notifications
assert 'json' not in NOTIFY_CUSTOM_MODULE_MAP[key]['notify'] assert 'json' not in N_MGR._custom_module_map[key]['notify']
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
'--plugin-path', join(str(tmpdir), 'complex'), '--plugin-path', join(str(tmpdir), 'complex'),

View File

@ -37,16 +37,21 @@ from apprise import AppriseConfig
from apprise import AppriseAsset from apprise import AppriseAsset
from apprise.config.ConfigBase import ConfigBase from apprise.config.ConfigBase import ConfigBase
from apprise.plugins.NotifyBase import NotifyBase from apprise.plugins.NotifyBase import NotifyBase
from apprise.NotificationManager import NotificationManager
from apprise.ConfigurationManager import ConfigurationManager
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 from apprise.config.ConfigFile import ConfigFile
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
# Grant access to our Configuration Manager Singleton
C_MGR = ConfigurationManager()
def test_apprise_config(tmpdir): def test_apprise_config(tmpdir):
""" """
@ -248,7 +253,7 @@ def test_apprise_multi_config_entries(tmpdir):
return '' return ''
# Store our good notification in our schema map # Store our good notification in our schema map
NOTIFY_SCHEMA_MAP['good'] = GoodNotification N_MGR._schema_map['good'] = GoodNotification
# Create ourselves a config object # Create ourselves a config object
ac = AppriseConfig() ac = AppriseConfig()
@ -468,7 +473,7 @@ def test_apprise_config_instantiate():
return ConfigBase.parse_url(url, verify_host=False) return ConfigBase.parse_url(url, verify_host=False)
# Store our bad configuration in our schema map # Store our bad configuration in our schema map
CONFIG_SCHEMA_MAP['bad'] = BadConfig C_MGR['bad'] = BadConfig
with pytest.raises(TypeError): with pytest.raises(TypeError):
AppriseConfig.instantiate( AppriseConfig.instantiate(
@ -501,7 +506,7 @@ def test_invalid_apprise_config(tmpdir):
return ConfigBase.parse_url(url, verify_host=False) return ConfigBase.parse_url(url, verify_host=False)
# Store our bad configuration in our schema map # Store our bad configuration in our schema map
CONFIG_SCHEMA_MAP['bad'] = BadConfig C_MGR['bad'] = BadConfig
# temporary file to work with # temporary file to work with
t = tmpdir.mkdir("apprise-bad-obj").join("invalid") t = tmpdir.mkdir("apprise-bad-obj").join("invalid")
@ -566,7 +571,7 @@ def test_apprise_config_with_apprise_obj(tmpdir):
return '' return ''
# Store our good notification in our schema map # Store our good notification in our schema map
NOTIFY_SCHEMA_MAP['good'] = GoodNotification N_MGR._schema_map['good'] = GoodNotification
# Create ourselves a config object # Create ourselves a config object
ac = AppriseConfig(cache=False) ac = AppriseConfig(cache=False)
@ -765,9 +770,9 @@ def test_recursive_config_inclusion(tmpdir):
allow_cross_includes = ContentIncludeMode.NEVER allow_cross_includes = ContentIncludeMode.NEVER
# store our entries # store our entries
CONFIG_SCHEMA_MAP['never'] = ConfigCrossPostNever C_MGR['never'] = ConfigCrossPostNever
CONFIG_SCHEMA_MAP['strict'] = ConfigCrossPostStrict C_MGR['strict'] = ConfigCrossPostStrict
CONFIG_SCHEMA_MAP['always'] = ConfigCrossPostAlways C_MGR['always'] = ConfigCrossPostAlways
# Make our new path valid # Make our new path valid
suite = tmpdir.mkdir("apprise_config_recursion") suite = tmpdir.mkdir("apprise_config_recursion")
@ -974,11 +979,6 @@ def test_apprise_config_matrix_load():
apprise.config.ConfigDummy3 = ConfigDummy3 apprise.config.ConfigDummy3 = ConfigDummy3
apprise.config.ConfigDummy4 = ConfigDummy4 apprise.config.ConfigDummy4 = ConfigDummy4
__load_matrix()
# Call it again so we detect our entries already loaded
__load_matrix()
def test_configmatrix_dynamic_importing(tmpdir): def test_configmatrix_dynamic_importing(tmpdir):
""" """
@ -1052,8 +1052,6 @@ class ConfigBugger(ConfigBase):
# always parseable # always parseable
return ConfigBase.parse_url(url, verify_host=False)""") return ConfigBase.parse_url(url, verify_host=False)""")
__load_matrix(path=str(base), name=module_name)
@mock.patch('os.path.getsize') @mock.patch('os.path.getsize')
def test_config_base_parse_inaccessible_text_file(mock_getsize, tmpdir): def test_config_base_parse_inaccessible_text_file(mock_getsize, tmpdir):

View File

@ -33,7 +33,7 @@ from inspect import cleandoc
from urllib.parse import unquote from urllib.parse import unquote
from apprise import utils from apprise import utils
from apprise import common from apprise.NotificationManager import NotificationManager
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
@ -42,6 +42,9 @@ logging.disable(logging.CRITICAL)
# Ensure we don't create .pyc files for these tests # Ensure we don't create .pyc files for these tests
sys.dont_write_bytecode = True sys.dont_write_bytecode = True
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
def test_parse_qsd(): def test_parse_qsd():
"utils: parse_qsd() testing """ "utils: parse_qsd() testing """
@ -2030,15 +2033,15 @@ def test_module_detection(tmpdir):
""" """
# Clear our working variables so they don't obstruct with the tests here # Clear our working variables so they don't obstruct with the tests here
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# Test case where we load invalid data # Test case where we load invalid data
utils.module_detection(None) N_MGR.module_detection(None)
# Invalid data does not load anything # Invalid data does not load anything
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 0 assert len(N_MGR._paths_previously_scanned) == 0
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Prepare ourselves a file to work with # Prepare ourselves a file to work with
notify_hook_a_base = tmpdir.mkdir('a') notify_hook_a_base = tmpdir.mkdir('a')
@ -2057,30 +2060,29 @@ def test_module_detection(tmpdir):
""")) """))
# Not previously loaded # Not previously loaded
assert 'clihook' not in common.NOTIFY_SCHEMA_MAP assert 'clihook' not in N_MGR
# load entry by string # load entry by string
utils.module_detection(str(notify_hook_a)) N_MGR.module_detection(str(notify_hook_a))
utils.module_detection(str(notify_ignore)) N_MGR.module_detection(str(notify_ignore))
utils.module_detection(str(notify_hook_a_base)) N_MGR.module_detection(str(notify_hook_a_base))
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 3 assert len(N_MGR._paths_previously_scanned) == 3
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# Now loaded # Now loaded
assert 'clihook' in common.NOTIFY_SCHEMA_MAP assert 'clihook' in N_MGR
# load entry by array # load entry by array
utils.module_detection([str(notify_hook_a)]) N_MGR.module_detection([str(notify_hook_a)])
# No changes to our path # No changes to our path
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 3 assert len(N_MGR._paths_previously_scanned) == 3
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# Reset our variables for the next test # Reset our variables for the next test
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
del common.NOTIFY_SCHEMA_MAP['clihook']
# Hidden files are ignored # Hidden files are ignored
notify_hook_b_base = tmpdir.mkdir('b') notify_hook_b_base = tmpdir.mkdir('b')
@ -2094,36 +2096,36 @@ def test_module_detection(tmpdir):
pass pass
""")) """))
assert 'hidden' not in common.NOTIFY_SCHEMA_MAP assert 'hidden' not in N_MGR
utils.module_detection([str(notify_hook_b)]) N_MGR.module_detection([str(notify_hook_b)])
# Verify that it did not load # Verify that it did not load
assert 'hidden' not in common.NOTIFY_SCHEMA_MAP assert 'hidden' not in N_MGR
# Path was scanned; nothing loaded # Path was scanned; nothing loaded
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Reset our variables for the next test # Reset our variables for the next test
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# modules with no hooks found are ignored # modules with no hooks found are ignored
notify_hook_c_base = tmpdir.mkdir('c') notify_hook_c_base = tmpdir.mkdir('c')
notify_hook_c = notify_hook_c_base.join('empty.py') notify_hook_c = notify_hook_c_base.join('empty.py')
notify_hook_c.write("") notify_hook_c.write("")
utils.module_detection([str(notify_hook_c)]) N_MGR.module_detection([str(notify_hook_c)])
# File was found, no custom modules # File was found, no custom modules
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# A new path scanned # A new path scanned
utils.module_detection([str(notify_hook_c_base)]) N_MGR.module_detection([str(notify_hook_c_base)])
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
def create_hook(tdir, cache=True, on="valid1"): def create_hook(tdir, cache=True, on="valid1"):
""" """
@ -2139,39 +2141,39 @@ def test_module_detection(tmpdir):
pass pass
""".format(on))) """.format(on)))
utils.module_detection([str(tdir)], cache=cache) N_MGR.module_detection([str(tdir)], cache=cache)
create_hook(notify_hook_c, on='valid1') create_hook(notify_hook_c, on='valid1')
assert 'valid1' not in common.NOTIFY_SCHEMA_MAP assert 'valid1' not in N_MGR
# Even if we correct our empty file; the fact the directory has been # 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 # scanned and failed to load (same with file), it won't be loaded
# a second time. This is intentional since module_detection() get's # a second time. This is intentional since module_detection() get's
# called for every AppriseAsset() object creation. This prevents us # called for every AppriseAsset() object creation. This prevents us
# from reloading conent over and over again wasting resources # from reloading conent over and over again wasting resources
assert 'valid1' not in common.NOTIFY_SCHEMA_MAP assert 'valid1' not in N_MGR
utils.module_detection([str(notify_hook_c)]) N_MGR.module_detection([str(notify_hook_c)])
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Even by absolute path... # Even by absolute path...
utils.module_detection([str(notify_hook_c)]) N_MGR.module_detection([str(notify_hook_c)])
assert 'valid1' not in common.NOTIFY_SCHEMA_MAP assert 'valid1' not in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# However we can bypass the cache if we really want to # However we can bypass the cache if we really want to
utils.module_detection([str(notify_hook_c_base)], cache=False) N_MGR.module_detection([str(notify_hook_c_base)], cache=False)
assert 'valid1' in common.NOTIFY_SCHEMA_MAP assert 'valid1' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# Bypassing it twice causes the module to load twice (not very efficient) # Bypassing it twice causes the module to load twice (not very efficient)
# However we can bypass the cache if we really want to # However we can bypass the cache if we really want to
utils.module_detection([str(notify_hook_c_base)], cache=False) N_MGR.module_detection([str(notify_hook_c_base)], cache=False)
assert 'valid1' in common.NOTIFY_SCHEMA_MAP assert 'valid1' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# If you update the module (corrupting it in the process and reload) # If you update the module (corrupting it in the process and reload)
notify_hook_c.write(cleandoc(""" notify_hook_c.write(cleandoc("""
@ -2179,50 +2181,50 @@ def test_module_detection(tmpdir):
""")) """))
# Force no cache to cause the file to be replaced # Force no cache to cause the file to be replaced
utils.module_detection([str(notify_hook_c_base)], cache=False) N_MGR.module_detection([str(notify_hook_c_base)], cache=False)
# Our valid entry is no longer loaded # Our valid entry is no longer loaded
assert 'valid1' not in common.NOTIFY_SCHEMA_MAP assert 'valid1' not in N_MGR
# No change to scanned paths # No change to scanned paths
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
# The previously loaded module is now gone # The previously loaded module is now gone
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Reload our valid1 entry # Reload our valid1 entry
create_hook(notify_hook_c, on='valid1', cache=False) create_hook(notify_hook_c, on='valid1', cache=False)
assert 'valid1' in common.NOTIFY_SCHEMA_MAP assert 'valid1' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# Prepare an empty file # Prepare an empty file
notify_hook_c.write("") notify_hook_c.write("")
utils.module_detection([str(notify_hook_c_base)], cache=False) N_MGR.module_detection([str(notify_hook_c_base)], cache=False)
# Our valid entry is no longer loaded # Our valid entry is no longer loaded
assert 'valid1' not in common.NOTIFY_SCHEMA_MAP assert 'valid1' not in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Now reload our module again (this time rather then an exception, the # Now reload our module again (this time rather then an exception, the
# module is read back and swaps `valid1` for `valid2` # module is read back and swaps `valid1` for `valid2`
create_hook(notify_hook_c, on='valid1', cache=False) create_hook(notify_hook_c, on='valid1', cache=False)
assert 'valid1' in common.NOTIFY_SCHEMA_MAP assert 'valid1' in N_MGR
assert 'valid2' not in common.NOTIFY_SCHEMA_MAP assert 'valid2' not in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
create_hook(notify_hook_c, on='valid2', cache=False) create_hook(notify_hook_c, on='valid2', cache=False)
assert 'valid1' not in common.NOTIFY_SCHEMA_MAP assert 'valid1' not in N_MGR
assert 'valid2' in common.NOTIFY_SCHEMA_MAP assert 'valid2' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# Reset our variables for the next test # Reset our variables for the next test
create_hook(notify_hook_c, on='valid1', cache=False) create_hook(notify_hook_c, on='valid1', cache=False)
del common.NOTIFY_SCHEMA_MAP['valid1'] del N_MGR['valid1']
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
notify_hook_d = notify_hook_c_base.join('.ignore.py') notify_hook_d = notify_hook_c_base.join('.ignore.py')
notify_hook_d.write("") notify_hook_d.write("")
@ -2241,71 +2243,71 @@ def test_module_detection(tmpdir):
# Try to load our base directory again; this time we search by the # 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 # 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 # even look at the .ignore.py file found since it is invalid
utils.module_detection([str(notify_hook_c_base)]) N_MGR.module_detection([str(notify_hook_c_base)])
assert 'valid1' in common.NOTIFY_SCHEMA_MAP assert 'valid1' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# Reset our variables for the next test # Reset our variables for the next test
del common.NOTIFY_SCHEMA_MAP['valid1'] del N_MGR._schema_map['valid1']
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# Try to load our base directory again # Try to load our base directory again
utils.module_detection([str(notify_hook_c)]) N_MGR.module_detection([str(notify_hook_c)])
assert 'valid1' in common.NOTIFY_SCHEMA_MAP assert 'valid1' in N_MGR
# Hidden directories are not scanned # Hidden directories are not scanned
assert 'valid2' not in common.NOTIFY_SCHEMA_MAP assert 'valid2' not in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert str(notify_hook_c) in utils.PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_c) in N_MGR._paths_previously_scanned
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# However a direct reference to the hidden directory is okay # However a direct reference to the hidden directory is okay
utils.module_detection([str(notify_hook_e_base)]) N_MGR.module_detection([str(notify_hook_e_base)])
# We loaded our module # We loaded our module
assert 'valid2' in common.NOTIFY_SCHEMA_MAP assert 'valid2' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 3 assert len(N_MGR._paths_previously_scanned) == 3
assert str(notify_hook_c) in utils.PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_c) in N_MGR._paths_previously_scanned
assert str(notify_hook_e) in utils.PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_e) in N_MGR._paths_previously_scanned
assert str(notify_hook_e_base) in utils.PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_e_base) in N_MGR._paths_previously_scanned
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 2 assert len(N_MGR._custom_module_map) == 2
# Reset our variables for the next test # Reset our variables for the next test
del common.NOTIFY_SCHEMA_MAP['valid1'] del N_MGR._schema_map['valid1']
del common.NOTIFY_SCHEMA_MAP['valid2'] del N_MGR._schema_map['valid2']
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# Load our file directly # Load our file directly
assert 'valid2' not in common.NOTIFY_SCHEMA_MAP assert 'valid2' not in N_MGR
utils.module_detection([str(notify_hook_e)]) N_MGR.module_detection([str(notify_hook_e)])
# Now we have it loaded as expected # Now we have it loaded as expected
assert 'valid2' in common.NOTIFY_SCHEMA_MAP assert 'valid2' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert str(notify_hook_e) in utils.PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_e) in N_MGR._paths_previously_scanned
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# however if we try to load the base directory where the __init__.py # however if we try to load the base directory where the __init__.py
# was already loaded from, it will not change anything # was already loaded from, it will not change anything
utils.module_detection([str(notify_hook_e_base)]) N_MGR.module_detection([str(notify_hook_e_base)])
assert 'valid2' in common.NOTIFY_SCHEMA_MAP assert 'valid2' in N_MGR
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 2 assert len(N_MGR._paths_previously_scanned) == 2
assert str(notify_hook_e) in utils.PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_e) in N_MGR._paths_previously_scanned
assert str(notify_hook_e_base) in utils.PATHS_PREVIOUSLY_SCANNED assert str(notify_hook_e_base) in N_MGR._paths_previously_scanned
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
# Tidy up for the next test # Tidy up for the next test
del common.NOTIFY_SCHEMA_MAP['valid2'] del N_MGR._schema_map['valid2']
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
assert 'valid1' not in common.NOTIFY_SCHEMA_MAP assert 'valid1' not in N_MGR
assert 'valid2' not in common.NOTIFY_SCHEMA_MAP assert 'valid2' not in N_MGR
assert 'valid3' not in common.NOTIFY_SCHEMA_MAP assert 'valid3' not in N_MGR
notify_hook_f_base = tmpdir.mkdir('f') notify_hook_f_base = tmpdir.mkdir('f')
notify_hook_f = notify_hook_f_base.join('invalid.py') notify_hook_f = notify_hook_f_base.join('invalid.py')
notify_hook_f.write(cleandoc(""" notify_hook_f.write(cleandoc("""
@ -2334,20 +2336,20 @@ def test_module_detection(tmpdir):
""")) """))
utils.module_detection([str(notify_hook_f)]) N_MGR.module_detection([str(notify_hook_f)])
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 1 assert len(N_MGR._custom_module_map) == 1
assert 'valid1' in common.NOTIFY_SCHEMA_MAP assert 'valid1' in N_MGR
assert 'valid2' in common.NOTIFY_SCHEMA_MAP assert 'valid2' in N_MGR
assert 'valid3' in common.NOTIFY_SCHEMA_MAP assert 'valid3' in N_MGR
# Reset our variables for the next test # Reset our variables for the next test
del common.NOTIFY_SCHEMA_MAP['valid1'] del N_MGR._schema_map['valid1']
del common.NOTIFY_SCHEMA_MAP['valid2'] del N_MGR._schema_map['valid2']
del common.NOTIFY_SCHEMA_MAP['valid3'] del N_MGR._schema_map['valid3']
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
# Now test the handling of just bad data entirely # Now test the handling of just bad data entirely
notify_hook_g_base = tmpdir.mkdir('g') notify_hook_g_base = tmpdir.mkdir('g')
@ -2355,13 +2357,13 @@ def test_module_detection(tmpdir):
with open(str(notify_hook_g), 'wb') as fout: with open(str(notify_hook_g), 'wb') as fout:
fout.write(os.urandom(512)) fout.write(os.urandom(512))
utils.module_detection([str(notify_hook_g)]) N_MGR.module_detection([str(notify_hook_g)])
assert len(utils.PATHS_PREVIOUSLY_SCANNED) == 1 assert len(N_MGR._paths_previously_scanned) == 1
assert len(common.NOTIFY_CUSTOM_MODULE_MAP) == 0 assert len(N_MGR._custom_module_map) == 0
# Reset our variables before we leave # Reset our variables before we leave
utils.PATHS_PREVIOUSLY_SCANNED.clear() N_MGR._paths_previously_scanned.clear()
common.NOTIFY_CUSTOM_MODULE_MAP.clear() N_MGR._custom_module_map.clear()
def test_exclusive_match(): def test_exclusive_match():

View File

@ -31,13 +31,15 @@ import pytest
from apprise import Apprise from apprise import Apprise
from apprise import NotifyBase from apprise import NotifyBase
from apprise import NotifyFormat from apprise import NotifyFormat
from apprise.NotificationManager import NotificationManager
from apprise.common import NOTIFY_SCHEMA_MAP
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
@pytest.mark.skipif(sys.version_info >= (3, 7), @pytest.mark.skipif(sys.version_info >= (3, 7),
reason="Requires Python 3.0 to 3.6") reason="Requires Python 3.0 to 3.6")
@ -65,7 +67,7 @@ def test_apprise_asyncio_runtime_error():
return NotifyBase.parse_url(url, verify_host=False) return NotifyBase.parse_url(url, verify_host=False)
# Store our good notification in our schema map # Store our good notification in our schema map
NOTIFY_SCHEMA_MAP['good'] = GoodNotification N_MGR['good'] = GoodNotification
# Create ourselves an Apprise object # Create ourselves an Apprise object
a = Apprise() a = Apprise()

View File

@ -36,8 +36,8 @@ from os.path import dirname
from os.path import getsize from os.path import getsize
from apprise.attachment.AttachHTTP import AttachHTTP from apprise.attachment.AttachHTTP import AttachHTTP
from apprise import AppriseAttachment from apprise import AppriseAttachment
from apprise.NotificationManager import NotificationManager
from apprise.plugins.NotifyBase import NotifyBase from apprise.plugins.NotifyBase import NotifyBase
from apprise.common import NOTIFY_SCHEMA_MAP
from apprise.common import ContentLocation from apprise.common import ContentLocation
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
@ -46,6 +46,9 @@ logging.disable(logging.CRITICAL)
TEST_VAR_DIR = join(dirname(__file__), 'var') TEST_VAR_DIR = join(dirname(__file__), 'var')
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
# Some exception handling we'll use # Some exception handling we'll use
REQUEST_EXCEPTIONS = ( REQUEST_EXCEPTIONS = (
requests.ConnectionError( requests.ConnectionError(
@ -131,7 +134,7 @@ def test_attach_http(mock_get):
return '' return ''
# Store our good notification in our schema map # Store our good notification in our schema map
NOTIFY_SCHEMA_MAP['good'] = GoodNotification N_MGR['good'] = GoodNotification
# Temporary path # Temporary path
path = join(TEST_VAR_DIR, 'apprise-test.gif') path = join(TEST_VAR_DIR, 'apprise-test.gif')

View File

@ -34,12 +34,14 @@ import requests
from apprise.common import ConfigFormat from apprise.common import ConfigFormat
from apprise.config.ConfigHTTP import ConfigHTTP from apprise.config.ConfigHTTP import ConfigHTTP
from apprise.plugins.NotifyBase import NotifyBase from apprise.plugins.NotifyBase import NotifyBase
from apprise.common import NOTIFY_SCHEMA_MAP from apprise.NotificationManager import NotificationManager
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
# Some exception handling we'll use # Some exception handling we'll use
REQUEST_EXCEPTIONS = ( REQUEST_EXCEPTIONS = (
@ -77,7 +79,7 @@ def test_config_http(mock_post):
return '' return ''
# Store our good notification in our schema map # Store our good notification in our schema map
NOTIFY_SCHEMA_MAP['good'] = GoodNotification N_MGR['good'] = GoodNotification
# Our default content # Our default content
default_content = """taga,tagb=good://server01""" default_content = """taga,tagb=good://server01"""

View File

@ -34,11 +34,15 @@ from apprise import AppriseConfig
from apprise import AppriseAsset from apprise import AppriseAsset
from apprise import AppriseAttachment from apprise import AppriseAttachment
from apprise import common from apprise import common
from apprise.NotificationManager import NotificationManager
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
TEST_VAR_DIR = join(dirname(__file__), 'var') TEST_VAR_DIR = join(dirname(__file__), 'var')
@ -48,7 +52,7 @@ def test_notify_simple_decoration():
# Verify our schema we're about to declare doesn't already exist # Verify our schema we're about to declare doesn't already exist
# in our schema map: # in our schema map:
assert 'utiltest' not in common.NOTIFY_SCHEMA_MAP assert 'utiltest' not in N_MGR
verify_obj = {} verify_obj = {}
@ -68,7 +72,7 @@ def test_notify_simple_decoration():
}) })
# Now after our hook being inline... it's been loaded # Now after our hook being inline... it's been loaded
assert 'utiltest' in common.NOTIFY_SCHEMA_MAP assert 'utiltest' in N_MGR
# Create ourselves an apprise object # Create ourselves an apprise object
aobj = Apprise() aobj = Apprise()
@ -146,7 +150,7 @@ def test_notify_simple_decoration():
assert verify_obj['kwargs']['meta']['schema'] == 'utiltest' assert verify_obj['kwargs']['meta']['schema'] == 'utiltest'
assert verify_obj['kwargs']['meta']['url'] == 'utiltest://' assert verify_obj['kwargs']['meta']['url'] == 'utiltest://'
assert 'notexc' not in common.NOTIFY_SCHEMA_MAP assert 'notexc' not in N_MGR
# Define a function here on the spot # Define a function here on the spot
@notify(on="notexc", name="Apprise @notify Exception Handling") @notify(on="notexc", name="Apprise @notify Exception Handling")
@ -154,7 +158,7 @@ def test_notify_simple_decoration():
body, title, notify_type, attach, *args, **kwargs): body, title, notify_type, attach, *args, **kwargs):
raise ValueError("An exception was thrown!") raise ValueError("An exception was thrown!")
assert 'notexc' in common.NOTIFY_SCHEMA_MAP assert 'notexc' in N_MGR
# Create ourselves an apprise object # Create ourselves an apprise object
aobj = Apprise() aobj = Apprise()
@ -165,8 +169,7 @@ def test_notify_simple_decoration():
assert aobj.notify("Exceptions will be thrown!") is False assert aobj.notify("Exceptions will be thrown!") is False
# Tidy # Tidy
del common.NOTIFY_SCHEMA_MAP['utiltest'] N_MGR.remove('utiltest', 'notexc')
del common.NOTIFY_SCHEMA_MAP['notexc']
def test_notify_complex_decoration(): def test_notify_complex_decoration():
@ -175,7 +178,7 @@ def test_notify_complex_decoration():
# Verify our schema we're about to declare doesn't already exist # Verify our schema we're about to declare doesn't already exist
# in our schema map: # in our schema map:
assert 'utiltest' not in common.NOTIFY_SCHEMA_MAP assert 'utiltest' not in N_MGR
verify_obj = {} verify_obj = {}
@ -196,7 +199,7 @@ def test_notify_complex_decoration():
}) })
# Now after our hook being inline... it's been loaded # Now after our hook being inline... it's been loaded
assert 'utiltest' in common.NOTIFY_SCHEMA_MAP assert 'utiltest' in N_MGR
# Create ourselves an apprise object # Create ourselves an apprise object
aobj = Apprise() aobj = Apprise()
@ -312,7 +315,7 @@ def test_notify_complex_decoration():
assert 'key2=another' in verify_obj['kwargs']['meta']['url'] assert 'key2=another' in verify_obj['kwargs']['meta']['url']
# Tidy # Tidy
del common.NOTIFY_SCHEMA_MAP['utiltest'] N_MGR.remove('utiltest')
def test_notify_multi_instance_decoration(tmpdir): def test_notify_multi_instance_decoration(tmpdir):
@ -321,7 +324,7 @@ def test_notify_multi_instance_decoration(tmpdir):
# Verify our schema we're about to declare doesn't already exist # Verify our schema we're about to declare doesn't already exist
# in our schema map: # in our schema map:
assert 'multi' not in common.NOTIFY_SCHEMA_MAP assert 'multi' not in N_MGR
verify_obj = [] verify_obj = []
@ -342,7 +345,7 @@ def test_notify_multi_instance_decoration(tmpdir):
}) })
# Now after our hook being inline... it's been loaded # Now after our hook being inline... it's been loaded
assert 'multi' in common.NOTIFY_SCHEMA_MAP assert 'multi' in N_MGR
# Prepare our config # Prepare our config
t = tmpdir.mkdir("multi-test").join("apprise.yml") t = tmpdir.mkdir("multi-test").join("apprise.yml")
@ -449,4 +452,4 @@ def test_notify_multi_instance_decoration(tmpdir):
assert meta['url'] == 'multi://user2:pass2@hostname' assert meta['url'] == 'multi://user2:pass2@hostname'
# Tidy # Tidy
del common.NOTIFY_SCHEMA_MAP['multi'] N_MGR.remove('multi')

View File

@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import re
import pytest
import types
from inspect import cleandoc
from apprise.NotificationManager import NotificationManager
from apprise.plugins.NotifyBase import NotifyBase
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
# Grant access to our Notification Manager Singleton
N_MGR = NotificationManager()
def test_notification_manager_general():
"""
N_MGR: Notification Manager General testing
"""
# Clear our set so we can test init calls
N_MGR.unload_modules()
assert isinstance(N_MGR.schemas(), list)
assert len(N_MGR.schemas()) > 0
N_MGR.unload_modules(disable_native=True)
assert isinstance(N_MGR.schemas(), list)
assert len(N_MGR.schemas()) == 0
N_MGR.unload_modules()
assert len(N_MGR) > 0
N_MGR.unload_modules()
iter(N_MGR)
iter(N_MGR)
N_MGR.unload_modules()
assert bool(N_MGR) is False
assert len([x for x in iter(N_MGR)]) > 0
assert bool(N_MGR) is True
N_MGR.unload_modules()
assert isinstance(N_MGR.plugins(), types.GeneratorType)
assert len([x for x in N_MGR.plugins()]) > 0
N_MGR.unload_modules(disable_native=True)
assert isinstance(N_MGR.plugins(), types.GeneratorType)
assert len([x for x in N_MGR.plugins()]) == 0
N_MGR.unload_modules()
assert isinstance(N_MGR['json'](host='localhost'), NotifyBase)
N_MGR.unload_modules()
assert 'json' in N_MGR
# Define our good:// url
class DisabledNotification(NotifyBase):
# Always disabled
enabled = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def notify(self, *args, **kwargs):
# Pretend everything is okay
return True
def url(self, **kwargs):
# Support url() function
return ''
# Define our good:// url
class GoodNotification(NotifyBase):
secure_protocol = ('good', 'goods')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def notify(self, *args, **kwargs):
# Pretend everything is okay
return True
def url(self, **kwargs):
# Support url() function
return ''
N_MGR.unload_modules()
assert N_MGR.add(GoodNotification)
assert 'good' in N_MGR
assert 'goods' in N_MGR
assert 'abcd' not in N_MGR
assert 'xyz' not in N_MGR
N_MGR.unload_modules()
assert N_MGR.add(GoodNotification, 'abcd')
assert 'good' in N_MGR
assert 'goods' in N_MGR
assert 'abcd' in N_MGR
assert 'xyz' not in N_MGR
N_MGR.unload_modules()
assert N_MGR.add(GoodNotification, ['abcd', 'xYz'])
assert 'good' in N_MGR
assert 'goods' in N_MGR
assert 'abcd' in N_MGR
# Lower case
assert 'xyz' in N_MGR
N_MGR.unload_modules()
# Not going to work; schemas must be a list of string
assert N_MGR.add(GoodNotification, object) is False
N_MGR.unload_modules()
with pytest.raises(KeyError):
del N_MGR['good']
N_MGR['good'] = GoodNotification
del N_MGR['good']
N_MGR.unload_modules()
N_MGR['good'] = GoodNotification
assert N_MGR['good'].enabled is True
N_MGR.enable_only('json', 'xml')
assert N_MGR['good'].enabled is False
assert N_MGR['json'].enabled is True
assert N_MGR['jsons'].enabled is True
assert N_MGR['xml'].enabled is True
assert N_MGR['xmls'].enabled is True
# Only two plugins are enabled
assert len([p for p in N_MGR.plugins(include_disabled=False)]) == 2
N_MGR.enable_only('good')
assert N_MGR['good'].enabled is True
assert N_MGR['json'].enabled is False
assert N_MGR['jsons'].enabled is False
assert N_MGR['xml'].enabled is False
assert N_MGR['xmls'].enabled is False
assert len([p for p in N_MGR.plugins(include_disabled=False)]) == 1
N_MGR.unload_modules()
N_MGR['disabled'] = DisabledNotification
assert N_MGR['disabled'].enabled is False
N_MGR.enable_only('disabled')
# Can't enable items that aren't supposed to be:
assert N_MGR['disabled'].enabled is False
N_MGR['good'] = GoodNotification
assert N_MGR['good'].enabled is True
# You can't disable someething already disabled
N_MGR.disable('disabled')
assert N_MGR['disabled'].enabled is False
N_MGR.unload_modules()
N_MGR.enable_only('form', 'xml')
for schema in N_MGR.schemas(include_disabled=False):
assert re.match(r'^(form|xml)s?$', schema, re.IGNORECASE) is not None
N_MGR.unload_modules()
assert N_MGR['form'].enabled is True
assert N_MGR['xml'].enabled is True
assert N_MGR['json'].enabled is True
N_MGR.enable_only('form', 'xml')
assert N_MGR['form'].enabled is True
assert N_MGR['xml'].enabled is True
assert N_MGR['json'].enabled is False
N_MGR.disable('invalid', 'xml')
assert N_MGR['form'].enabled is True
assert N_MGR['xml'].enabled is False
assert N_MGR['json'].enabled is False
# Detect that our json object is enabled
with pytest.raises(KeyError):
# The below can not be indexed
N_MGR['invalid']
N_MGR.unload_modules()
N_MGR.disable('invalid', 'xml')
N_MGR.unload_modules()
assert N_MGR['json'].enabled is True
# Work with an empty module tree
N_MGR.unload_modules(disable_native=True)
with pytest.raises(KeyError):
# The below can not be indexed
N_MGR['good']
N_MGR.unload_modules()
assert 'hello' not in N_MGR
assert 'good' not in N_MGR
assert 'goods' not in N_MGR
N_MGR['hello'] = GoodNotification
assert 'hello' in N_MGR
assert 'good' in N_MGR
assert 'goods' in N_MGR
N_MGR.unload_modules()
N_MGR['good'] = GoodNotification
with pytest.raises(KeyError):
# Can not assign the value again without getting a Conflict
N_MGR['good'] = GoodNotification
N_MGR.unload_modules()
N_MGR.remove('good', 'invalid')
assert 'good' not in N_MGR
assert 'goods' not in N_MGR
def test_notification_manager_module_loading(tmpdir):
"""
N_MGR: Notification Manager Module Loading
"""
# Handle loading modules twice (they gracefully handle not loading more in
# memory then needed)
N_MGR.load_modules()
N_MGR.load_modules()
def test_notification_manager_decorators(tmpdir):
"""
N_MGR: Notification Manager Decorator testing
"""
# Prepare ourselves a file to work with
notify_hook = tmpdir.mkdir('goodmodule').join('__init__.py')
notify_hook.write(cleandoc("""
from apprise.decorators import notify
# We want to trigger on anyone who configures a call to clihook://
@notify(on="clihooka")
def mywrapper(body, title, notify_type, *args, **kwargs):
# A simple test - print to screen
print("A {}: {} - {}".format(notify_type, title, body))
# No return (so a return of None) get's translated to True
# Define another in the same file; uppercase goes to lower
@notify(on="CLIhookb")
def mywrapper(body, title, notify_type, *args, **kwargs):
# A simple test - print to screen
print("B {}: {} - {}".format(notify_type, title, body))
# No return (so a return of None) get's translated to True
"""))
N_MGR.module_detection(str(notify_hook))
assert 'clihooka' in N_MGR
assert 'clihookb' in N_MGR
N_MGR.unload_modules()
assert 'clihooka' not in N_MGR
assert 'clihookb' not in N_MGR
N_MGR.module_detection(str(notify_hook))
assert 'clihooka' in N_MGR
assert 'clihookb' in N_MGR
del N_MGR['clihookb']
assert 'clihooka' in N_MGR
assert 'clihookb' not in N_MGR
del N_MGR['clihooka']
assert 'clihooka' not in N_MGR
assert 'clihookb' not in N_MGR
# Prepare ourselves a file to work with
notify_base = tmpdir.mkdir('plugins')
notify_test = notify_base.join('NotifyTest.py')
notify_test.write(cleandoc("""
#
# Bare Minimum Valid Object
#
from apprise.plugins.NotifyBase import NotifyBase
from apprise.common import NotifyType
class NotifyTest(NotifyBase):
service_name = 'Test'
# The services URL
service_url = 'https://github.com/caronc/apprise/'
# All boxcar notifications are secure
secure_protocol = 'mytest'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mytest'
# Define object templates
templates = (
'{schema}://',
)
def __init__(self, **kwargs):
super().__init__(**kwargs)
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
return True
def url(self):
return 'mytest://'
"""))
assert 'mytest' not in N_MGR
N_MGR.load_modules(path=str(notify_base))
assert 'mytest' in N_MGR
del N_MGR['mytest']
assert 'mytest' not in N_MGR
# Prepare ourselves a file to work with
notify_test = notify_base.join('NotifyNoAlign.py')
notify_test.write(cleandoc("""
#
# Bare Minimum Valid Object
#
from apprise.plugins.NotifyBase import NotifyBase
from apprise.common import NotifyType
class NotifyDifferentName(NotifyBase):
service_name = 'Unloadable'
# The services URL
service_url = 'https://github.com/caronc/apprise/'
# All boxcar notifications are secure
secure_protocol = 'noload'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mytest'
# Define object templates
templates = (
'{schema}://',
)
def __init__(self, **kwargs):
super().__init__(**kwargs)
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
return True
def url(self):
return 'mytest://'
"""))
assert 'mytest' not in N_MGR
N_MGR.load_modules(path=str(notify_base))
assert 'mytest' in N_MGR
# Could not be loaded because the filename did not align with the class
# name.
assert 'noload' not in N_MGR
# Double load will test section of code that prevents a notification
# From reloading if previously already loaded
N_MGR.load_modules(path=str(notify_base))
# Our item is still loaded as expected
assert 'mytest' in N_MGR

View File

@ -114,8 +114,7 @@ def setup_glib_environment():
# When patching something which has a side effect on the module-level code # When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it. # of a plugin, make sure to reload it.
current_module = sys.modules[__name__] reload_plugin('NotifyDBus')
reload_plugin('NotifyDBus', replace_in=current_module)
@pytest.fixture @pytest.fixture
@ -416,8 +415,7 @@ def test_plugin_dbus_gi_missing(dbus_glib_environment):
# When patching something which has a side effect on the module-level code # When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it. # of a plugin, make sure to reload it.
current_module = sys.modules[__name__] reload_plugin('NotifyDBus')
reload_plugin('NotifyDBus', replace_in=current_module)
# Create the instance. # Create the instance.
obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False)
@ -444,8 +442,7 @@ def test_plugin_dbus_gi_require_version_error(dbus_glib_environment):
# When patching something which has a side effect on the module-level code # When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it. # of a plugin, make sure to reload it.
current_module = sys.modules[__name__] reload_plugin('NotifyDBus')
reload_plugin('NotifyDBus', replace_in=current_module)
# Create instance. # Create instance.
obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False)
@ -472,8 +469,7 @@ def test_plugin_dbus_module_croaks(mocker, dbus_glib_environment):
# When patching something which has a side effect on the module-level code # When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it. # of a plugin, make sure to reload it.
current_module = sys.modules[__name__] reload_plugin('NotifyDBus')
reload_plugin('NotifyDBus', replace_in=current_module)
# Verify plugin is not available. # Verify plugin is not available.
obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('glib://', suppress_exceptions=False)

View File

@ -97,8 +97,7 @@ def setup_glib_environment():
# When patching something which has a side effect on the module-level code # When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it. # of a plugin, make sure to reload it.
current_module = sys.modules[__name__] reload_plugin('NotifyGnome')
reload_plugin('NotifyGnome', replace_in=current_module)
@pytest.fixture @pytest.fixture
@ -345,8 +344,7 @@ def test_plugin_gnome_gi_croaks():
# When patching something which has a side effect on the module-level code # When patching something which has a side effect on the module-level code
# of a plugin, make sure to reload it. # of a plugin, make sure to reload it.
current_module = sys.modules[__name__] reload_plugin('NotifyGnome')
reload_plugin('NotifyGnome', replace_in=current_module)
# Create instance. # Create instance.
obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False) obj = apprise.Apprise.instantiate('gnome://', suppress_exceptions=False)

View File

@ -43,7 +43,7 @@ logging.disable(logging.CRITICAL)
if sys.platform not in ["darwin", "linux"]: if sys.platform not in ["darwin", "linux"]:
pytest.skip("Only makes sense on macOS, but also works on Linux", pytest.skip("Only makes sense on macOS, but testable in Linux",
allow_module_level=True) allow_module_level=True)
@ -56,8 +56,7 @@ def pretend_macos(mocker):
mocker.patch("platform.mac_ver", return_value=('10.8', ('', '', ''), '')) mocker.patch("platform.mac_ver", return_value=('10.8', ('', '', ''), ''))
# Reload plugin module, in order to re-run module-level code. # Reload plugin module, in order to re-run module-level code.
current_module = sys.modules[__name__] reload_plugin("NotifyMacOSX")
reload_plugin("NotifyMacOSX", replace_in=current_module)
@pytest.fixture @pytest.fixture
@ -93,6 +92,7 @@ def test_plugin_macosx_general_success(macos_notify_environment):
NotifyMacOSX() general checks NotifyMacOSX() general checks
""" """
# Toggle Enable Flag
obj = apprise.Apprise.instantiate( obj = apprise.Apprise.instantiate(
'macosx://_/?image=True', suppress_exceptions=False) 'macosx://_/?image=True', suppress_exceptions=False)
assert isinstance(obj, NotifyMacOSX) is True assert isinstance(obj, NotifyMacOSX) is True