Group/Alias Configuration Support (#967)

pull/971/head
Chris Caron 2023-10-07 17:01:06 -04:00 committed by GitHub
parent c34a44fe5f
commit fdc85f502d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 507 additions and 32 deletions

View File

@ -356,6 +356,77 @@ class ConfigBase(URLBase):
# missing and/or expired. # missing and/or expired.
return True return True
@staticmethod
def __normalize_tag_groups(group_tags):
"""
Used to normalize a tag assign map which looks like:
{
'group': set('{tag1}', '{group1}', '{tag2}'),
'group1': set('{tag2}','{tag3}'),
}
Then normalized it (merging groups); with respect to the above, the
output would be:
{
'group': set('{tag1}', '{tag2}', '{tag3}),
'group1': set('{tag2}','{tag3}'),
}
"""
# Prepare a key set list we can use
tag_groups = set([str(x) for x in group_tags.keys()])
def _expand(tags, ignore=None):
"""
Expands based on tag provided and returns a set
this also updates the group_tags while it goes
"""
# Prepare ourselves a return set
results = set()
ignore = set() if ignore is None else ignore
# track groups
groups = set()
for tag in tags:
if tag in ignore:
continue
# Track our groups
groups.add(tag)
# Store what we know is worth keping
results |= group_tags[tag] - tag_groups
# Get simple tag assignments
found = group_tags[tag] & tag_groups
if not found:
continue
for gtag in found:
if gtag in ignore:
continue
# Go deeper (recursion)
ignore.add(tag)
group_tags[gtag] = _expand(set([gtag]), ignore=ignore)
results |= group_tags[gtag]
# Pop ignore
ignore.remove(tag)
return results
for tag in tag_groups:
# Get our tags
group_tags[tag] |= _expand(set([tag]))
if not group_tags[tag]:
ConfigBase.logger.warning(
'The group {} has no tags assigned to it'.format(tag))
del group_tags[tag]
@staticmethod @staticmethod
def parse_url(url, verify_host=True): def parse_url(url, verify_host=True):
"""Parses the URL and returns it broken apart into a dictionary. """Parses the URL and returns it broken apart into a dictionary.
@ -541,6 +612,9 @@ class ConfigBase(URLBase):
# as additional configuration entries when loaded. # as additional configuration entries when loaded.
include <ConfigURL> include <ConfigURL>
# Assign tag contents to a group identifier
<Group(s)>=<Tag(s)>
""" """
# A list of loaded Notification Services # A list of loaded Notification Services
servers = list() servers = list()
@ -549,6 +623,12 @@ class ConfigBase(URLBase):
# the include keyword # the include keyword
configs = list() configs = list()
# Track all of the tags we want to assign later on
group_tags = {}
# Track our entries to preload
preloaded = []
# Prepare our Asset Object # Prepare our Asset Object
asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()
@ -556,7 +636,7 @@ class ConfigBase(URLBase):
valid_line_re = re.compile( valid_line_re = re.compile(
r'^\s*(?P<line>([;#]+(?P<comment>.*))|' r'^\s*(?P<line>([;#]+(?P<comment>.*))|'
r'(\s*(?P<tags>[a-z0-9, \t_-]+)\s*=|=)?\s*' r'(\s*(?P<tags>[a-z0-9, \t_-]+)\s*=|=)?\s*'
r'(?P<url>[a-z0-9]{2,9}://.*)|' r'((?P<url>[a-z0-9]{2,9}://.*)|(?P<assign>[a-z0-9, \t_-]+))|'
r'include\s+(?P<config>.+))?\s*$', re.I) r'include\s+(?P<config>.+))?\s*$', re.I)
try: try:
@ -582,8 +662,13 @@ class ConfigBase(URLBase):
# otherwise. # otherwise.
return (list(), list()) return (list(), list())
url, config = result.group('url'), result.group('config') # Retrieve our line
if not (url or config): url, assign, config = \
result.group('url'), \
result.group('assign'), \
result.group('config')
if not (url or config or assign):
# Comment/empty line; do nothing # Comment/empty line; do nothing
continue continue
@ -603,6 +688,33 @@ class ConfigBase(URLBase):
loggable_url = url if not asset.secure_logging \ loggable_url = url if not asset.secure_logging \
else cwe312_url(url) else cwe312_url(url)
if assign:
groups = set(parse_list(result.group('tags'), cast=str))
if not groups:
# no tags were assigned
ConfigBase.logger.warning(
'Unparseable tag assignment - no group(s) '
'on line {}'.format(line))
continue
# Get our tags
tags = set(parse_list(assign, cast=str))
if not tags:
# no tags were assigned
ConfigBase.logger.warning(
'Unparseable tag assignment - no tag(s) to assign '
'on line {}'.format(line))
continue
# Update our tag group map
for tag_group in groups:
if tag_group not in group_tags:
group_tags[tag_group] = set()
# ensure our tag group is never included in the assignment
group_tags[tag_group] |= tags - set([tag_group])
continue
# Acquire our url tokens # Acquire our url tokens
results = plugins.url_to_dict( results = plugins.url_to_dict(
url, secure_logging=asset.secure_logging) url, secure_logging=asset.secure_logging)
@ -615,25 +727,57 @@ class ConfigBase(URLBase):
# Build a list of tags to associate with the newly added # Build a list of tags to associate with the newly added
# notifications if any were set # notifications if any were set
results['tag'] = set(parse_list(result.group('tags'))) results['tag'] = set(parse_list(result.group('tags'), cast=str))
# Set our Asset Object # Set our Asset Object
results['asset'] = asset results['asset'] = asset
# Store our preloaded entries
preloaded.append({
'results': results,
'line': line,
'loggable_url': loggable_url,
})
#
# Normalize Tag Groups
# - Expand Groups of Groups so that they don't exist
#
ConfigBase.__normalize_tag_groups(group_tags)
#
# URL Processing
#
for entry in preloaded:
# Point to our results entry for easier reference below
results = entry['results']
#
# Apply our tag groups if they're defined
#
for group, tags in group_tags.items():
# Detect if anything assigned to this tag also maps back to a
# group. If so we want to add the group to our list
if next((True for tag in results['tag']
if tag in tags), False):
results['tag'].add(group)
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[results['schema']](**results) plugin = common.NOTIFY_SCHEMA_MAP[
results['schema']](**results)
# Create log entry of loaded URL # Create log entry of loaded URL
ConfigBase.logger.debug( ConfigBase.logger.debug(
'Loaded URL: %s', plugin.url(privacy=asset.secure_logging)) 'Loaded URL: %s', plugin.url(
privacy=results['asset'].secure_logging))
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.
ConfigBase.logger.warning( ConfigBase.logger.warning(
'Could not load URL {} on line {}.'.format( 'Could not load URL {} on line {}.'.format(
loggable_url, line)) entry['loggable_url'], entry['line']))
ConfigBase.logger.debug('Loading Exception: %s' % str(e)) ConfigBase.logger.debug('Loading Exception: %s' % str(e))
continue continue
@ -665,6 +809,12 @@ class ConfigBase(URLBase):
# the include keyword # the include keyword
configs = list() configs = list()
# Group Assignments
group_tags = {}
# Track our entries to preload
preloaded = []
try: try:
# Load our data (safely) # Load our data (safely)
result = yaml.load(content, Loader=yaml.SafeLoader) result = yaml.load(content, Loader=yaml.SafeLoader)
@ -746,7 +896,45 @@ class ConfigBase(URLBase):
tags = result.get('tag', None) tags = result.get('tag', None)
if tags and isinstance(tags, (list, tuple, str)): if tags and isinstance(tags, (list, tuple, str)):
# Store any preset tags # Store any preset tags
global_tags = set(parse_list(tags)) global_tags = set(parse_list(tags, cast=str))
#
# groups root directive
#
groups = result.get('groups', None)
if not isinstance(groups, (list, tuple)):
# Not a problem; we simply have no group entry
groups = list()
# Iterate over each group defined and store it
for no, entry in enumerate(groups):
if not isinstance(entry, dict):
ConfigBase.logger.warning(
'No assignment for group {}, entry #{}'.format(
entry, no + 1))
continue
for _groups, tags in entry.items():
for group in parse_list(_groups, cast=str):
if isinstance(tags, (list, tuple)):
_tags = set()
for e in tags:
if isinstance(e, dict):
_tags |= set(e.keys())
else:
_tags |= set(parse_list(e, cast=str))
# Final assignment
tags = _tags
else:
tags = set(parse_list(tags, cast=str))
if group not in group_tags:
group_tags[group] = tags
else:
group_tags[group] |= tags
# #
# include root directive # include root directive
@ -938,8 +1126,8 @@ class ConfigBase(URLBase):
# The below ensures our tags are set correctly # The below ensures our tags are set correctly
if 'tag' in _results: if 'tag' in _results:
# Tidy our list up # Tidy our list up
_results['tag'] = \ _results['tag'] = set(
set(parse_list(_results['tag'])) | global_tags parse_list(_results['tag'], cast=str)) | global_tags
else: else:
# Just use the global settings # Just use the global settings
@ -965,29 +1153,59 @@ class ConfigBase(URLBase):
# Prepare our Asset Object # Prepare our Asset Object
_results['asset'] = asset _results['asset'] = asset
# Now we generate our plugin # Store our preloaded entries
try: preloaded.append({
# Attempt to create an instance of our plugin using the 'results': _results,
# parsed URL information 'entry': no + 1,
plugin = common.\ 'item': entry,
NOTIFY_SCHEMA_MAP[_results['schema']](**_results) })
# Create log entry of loaded URL #
ConfigBase.logger.debug( # Normalize Tag Groups
'Loaded URL: {}'.format( # - Expand Groups of Groups so that they don't exist
plugin.url(privacy=asset.secure_logging))) #
ConfigBase.__normalize_tag_groups(group_tags)
except Exception as e: #
# the arguments are invalid or can not be used. # URL Processing
ConfigBase.logger.warning( #
'Could not load Apprise YAML configuration ' for entry in preloaded:
'entry #{}, item #{}' # Point to our results entry for easier reference below
.format(no + 1, entry)) results = entry['results']
ConfigBase.logger.debug('Loading Exception: %s' % str(e))
continue
# if we reach here, we successfully loaded our data #
servers.append(plugin) # Apply our tag groups if they're defined
#
for group, tags in group_tags.items():
# Detect if anything assigned to this tag also maps back to a
# group. If so we want to add the group to our list
if next((True for tag in results['tag']
if tag in tags), False):
results['tag'].add(group)
# Now we generate our plugin
try:
# Attempt to create an instance of our plugin using the
# parsed URL information
plugin = common.\
NOTIFY_SCHEMA_MAP[results['schema']](**results)
# Create log entry of loaded URL
ConfigBase.logger.debug(
'Loaded URL: %s', plugin.url(
privacy=results['asset'].secure_logging))
except Exception as e:
# the arguments are invalid or can not be used.
ConfigBase.logger.warning(
'Could not load Apprise YAML configuration '
'entry #{}, item #{}'
.format(entry['entry'], entry['item']))
ConfigBase.logger.debug('Loading Exception: %s' % str(e))
continue
# if we reach here, we successfully loaded our data
servers.append(plugin)
return (servers, configs) return (servers, configs)

View File

@ -1120,7 +1120,7 @@ def urlencode(query, doseq=False, safe='', encoding=None, errors=None):
errors=errors) errors=errors)
def parse_list(*args): def parse_list(*args, cast=None):
""" """
Take a string list and break it into a delimited Take a string list and break it into a delimited
list of arguments. This funciton also supports list of arguments. This funciton also supports
@ -1143,6 +1143,9 @@ def parse_list(*args):
result = [] result = []
for arg in args: for arg in args:
if not isinstance(arg, (str, set, list, bool, tuple)) and arg and cast:
arg = cast(arg)
if isinstance(arg, str): if isinstance(arg, str):
result += re.split(STRING_DELIMITERS, arg) result += re.split(STRING_DELIMITERS, arg)
@ -1155,7 +1158,6 @@ def parse_list(*args):
# Since Python v3 returns a filter (iterator) whereas Python v2 returned # Since Python v3 returns a filter (iterator) whereas Python v2 returned
# a list, we need to change it into a list object to remain compatible with # a list, we need to change it into a list object to remain compatible with
# both distribution types. # both distribution types.
# TODO: Review after dropping support for Python 2.
return sorted([x for x in filter(bool, list(set(result)))]) return sorted([x for x in filter(bool, list(set(result)))])

View File

@ -33,6 +33,7 @@
import pytest import pytest
from apprise.AppriseAsset import AppriseAsset from apprise.AppriseAsset import AppriseAsset
from apprise.config.ConfigBase import ConfigBase from apprise.config.ConfigBase import ConfigBase
from apprise import Apprise
from apprise import ConfigFormat from apprise import ConfigFormat
from inspect import cleandoc from inspect import cleandoc
import yaml import yaml
@ -357,6 +358,149 @@ def test_config_base_config_parse_text():
assert 'tag3' in result[0].tags assert 'tag3' in result[0].tags
def test_config_base_config_tag_groups_text():
"""
API: ConfigBase.config_tag_groups_text object
"""
# Valid Configuration
result, config = ConfigBase.config_parse_text("""
# Tag assignments
groupA, groupB = tagB, tagC
# groupB doubles down as it takes the entries initialized above
# plus the added ones defined below
groupB = tagA, tagB, tagD
groupC = groupA, groupB, groupC, tagE
# Tag that recursively looks to more tags
groupD = groupC
# Assigned ourselves
groupX = groupX
# Set up a recursive loop
groupE = groupF
groupF = groupE
# Set up a larger recursive loop
groupG = groupH
groupH = groupI
groupI = groupJ
groupJ = groupK
groupK = groupG
# Bad assignments
groupM = , , ,
, , = , , ,
# int's and floats are okay
1 = 2
a = 5
# A comment line over top of a URL
4, groupB = mailto://userb:pass@gmail.com
# Tag Assignments
tagA,groupB=json://localhost
# More Tag Assignments
tagC,groupB=xml://localhost
# More Tag Assignments
groupD=form://localhost
""", asset=AppriseAsset())
# We expect to parse 3 entries from the above
assert isinstance(result, list)
assert isinstance(config, list)
assert len(result) == 4
# Our first element is our group tags
assert len(result[0].tags) == 2
assert 'groupB' in result[0].tags
assert '4' in result[0].tags
# No additional configuration is loaded
assert len(config) == 0
apobj = Apprise()
assert apobj.add(result)
# We match against 1 entry
assert len([x for x in apobj.find('tagA')]) == 1
assert len([x for x in apobj.find('tagB')]) == 0
assert len([x for x in apobj.find('groupA')]) == 1
assert len([x for x in apobj.find('groupB')]) == 3
assert len([x for x in apobj.find('groupC')]) == 2
assert len([x for x in apobj.find('groupD')]) == 3
# Invalid Assignment
result, config = ConfigBase.config_parse_text("""
# Must have something to equal or it's a bad line
group =
# A tag Assignments that is never gotten to as the line
# above is bad
groupD=form://localhost
""")
# We expect to parse 3 entries from the above
assert isinstance(result, list)
assert isinstance(config, list)
assert len(result) == 0
assert len(config) == 0
# Invalid Assignment
result, config = ConfigBase.config_parse_text("""
# Rundant assignment
group = group
# Our group assignment
group=windows://
""")
# the redundant assignment does us no harm; but it doesn't grant us any
# value either
assert isinstance(result, list)
assert len(result) == 1
# Our first element is our group tags
assert len(result[0].tags) == 1
assert 'group' in result[0].tags
# There were no include entries defined
assert len(config) == 0
# More invalid data
result, config = ConfigBase.config_parse_text("""
# A tag without a url or group assignment
taga=
""")
# We expect to parse 0 entries from the above
assert isinstance(result, list)
assert len(result) == 0
# There were no include entries defined
assert len(config) == 0
result, config = ConfigBase.config_parse_text("""
# A tag without a url or group assignment
taga= %%INVALID
""")
# We expect to parse 0 entries from the above
assert isinstance(result, list)
assert len(result) == 0
# There were no include entries defined
assert len(config) == 0
def test_config_base_config_parse_text_with_url(): def test_config_base_config_parse_text_with_url():
""" """
API: ConfigBase.config_parse_text object_with_url API: ConfigBase.config_parse_text object_with_url
@ -1015,6 +1159,117 @@ def test_yaml_vs_text_tagging():
assert 'mytag' in yaml_result[0] assert 'mytag' in yaml_result[0]
def test_config_base_config_tag_groups_yaml():
"""
API: ConfigBase.config_tag_groups_yaml object
"""
# general reference used below
asset = AppriseAsset()
# Valid Configuration
result, config = ConfigBase.config_parse_yaml("""
# if no version is specified then version 1 is presumed
version: 1
groups:
- group1: tagB, tagC, tagNotAssigned
- group2:
- tagA
- tagC
- group3:
- tagD: optional comment
- tagA: optional comment #2
# No assignment
- group4
# No assignment type 2
- group5:
# Integer assignment
- group6: 3
- group6: 3, 4, 5, test
- group6: 3.5, tagC
# Recursion
- groupA: groupB
- groupB: groupA
# And Again... (just because)
- groupA: groupB
- groupB: groupA
# Self assignment
- groupX: groupX
# Set up a larger recursive loop
- groupG: groupH
- groupH: groupI, groupJ
- groupI: groupJ, groupG
- groupJ: groupK, groupH, groupI
- groupK: groupG
# No tags assigned
- groupK: ",, , ,"
- " , ": ",, , ,"
# Multi Assignments
- groupL, groupM: tagD, tagA
- 4, groupN:
- tagD
- tagE, TagA
# Add one more tag to groupL making it different then GroupM by 1
- groupL: tagB
#
# Define your notification urls:
#
urls:
- form://localhost:
- tag: tagA
- mailto://test:password@gmail.com:
- tag: tagB
- xml://localhost:
- tag: tagC
- json://localhost:
- tag: tagD, tagA
""", asset=asset)
# We expect to parse 3 entries from the above
assert isinstance(result, list)
assert isinstance(config, list)
assert len(result) == 4
# Our first element is our group tags
assert len(result[0].tags) == 5
assert 'group2' in result[0].tags
assert 'group3' in result[0].tags
assert 'groupL' in result[0].tags
assert 'groupM' in result[0].tags
assert 'tagA' in result[0].tags
# No additional configuration is loaded
assert len(config) == 0
apobj = Apprise()
assert apobj.add(result)
# We match against 1 entry
assert len([x for x in apobj.find('tagA')]) == 2
assert len([x for x in apobj.find('tagB')]) == 1
assert len([x for x in apobj.find('tagC')]) == 1
assert len([x for x in apobj.find('tagD')]) == 1
assert len([x for x in apobj.find('group1')]) == 2
assert len([x for x in apobj.find('group2')]) == 3
assert len([x for x in apobj.find('group3')]) == 2
assert len([x for x in apobj.find('group4')]) == 0
assert len([x for x in apobj.find('group5')]) == 0
assert len([x for x in apobj.find('group6')]) == 2
assert len([x for x in apobj.find('4')]) == 1
assert len([x for x in apobj.find('groupN')]) == 1
def test_config_base_config_parse_yaml_globals(): def test_config_base_config_parse_yaml_globals():
""" """
API: ConfigBase.config_parse_yaml globals API: ConfigBase.config_parse_yaml globals