diff --git a/apprise/AppriseAttachment.py b/apprise/AppriseAttachment.py index 1a79f82f..a8f27e17 100644 --- a/apprise/AppriseAttachment.py +++ b/apprise/AppriseAttachment.py @@ -102,7 +102,7 @@ class AppriseAttachment(object): # Initialize our default cache value cache = cache if cache is not None else self.cache - if isinstance(asset, AppriseAsset): + if asset is None: # prepare default asset asset = self.asset diff --git a/apprise/AppriseConfig.py b/apprise/AppriseConfig.py index 95070012..902dfa6d 100644 --- a/apprise/AppriseConfig.py +++ b/apprise/AppriseConfig.py @@ -115,7 +115,7 @@ class AppriseConfig(object): # Initialize our default cache value cache = cache if cache is not None else self.cache - if isinstance(asset, AppriseAsset): + if asset is None: # prepare default asset asset = self.asset @@ -165,6 +165,39 @@ class AppriseConfig(object): # Return our status return return_status + def add_config(self, content, asset=None, tag=None, format=None): + """ + Adds one configuration file in it's raw format. Content gets loaded as + a memory based object and only exists for the life of this + AppriseConfig object it was loaded into. + + If you know the format ('yaml' or 'text') you can specify + it for slightly less overhead during this call. Otherwise the + configuration is auto-detected. + """ + + if asset is None: + # prepare default asset + asset = self.asset + + if not isinstance(content, six.string_types): + logger.warning( + "An invalid configuration (type={}) was specified.".format( + type(content))) + return False + + logger.debug("Loading raw configuration: {}".format(content)) + + # Create ourselves a ConfigMemory Object to store our configuration + instance = config.ConfigMemory( + content=content, format=format, asset=asset, tag=tag) + + # Add our initialized plugin to our server listings + self.configs.append(instance) + + # Return our status + return True + def servers(self, tag=MATCH_ALL_TAG, *args, **kwargs): """ Returns all of our servers dynamically build based on parsed diff --git a/apprise/config/ConfigBase.py b/apprise/config/ConfigBase.py index 539d4c49..8cd40813 100644 --- a/apprise/config/ConfigBase.py +++ b/apprise/config/ConfigBase.py @@ -92,7 +92,8 @@ class ConfigBase(URLBase): # Store the encoding self.encoding = kwargs.get('encoding') - if 'format' in kwargs: + if 'format' in kwargs \ + and isinstance(kwargs['format'], six.string_types): # Store the enforced config format self.config_format = kwargs.get('format').lower() @@ -249,6 +250,109 @@ class ConfigBase(URLBase): return results + @staticmethod + def detect_config_format(content, **kwargs): + """ + Takes the specified content and attempts to detect the format type + + The function returns the actual format type if detected, otherwise + it returns None + """ + + # Detect Format Logic: + # - A pound/hashtag (#) is alawys a comment character so we skip over + # lines matched here. + # - Detection begins on the first non-comment and non blank line + # matched. + # - If we find a string followed by a colon, we know we're dealing + # with a YAML file. + # - If we find a string that starts with a URL, or our tag + # definitions (accepting commas) followed by an equal sign we know + # we're dealing with a TEXT format. + + # Define what a valid line should look like + valid_line_re = re.compile( + r'^\s*(?P([;#]+(?P.*))|' + r'(?P((?P[ \t,a-z0-9_-]+)=)?[a-z0-9]+://.*)|' + r'((?P[a-z0-9]+):.*))?$', re.I) + + try: + # split our content up to read line by line + content = re.split(r'\r*\n', content) + + except TypeError: + # content was not expected string type + ConfigBase.logger.error('Invalid apprise config specified') + return None + + # By default set our return value to None since we don't know + # what the format is yet + config_format = None + + # iterate over each line of the file to attempt to detect it + # stop the moment a the type has been determined + for line, entry in enumerate(content, start=1): + + result = valid_line_re.match(entry) + if not result: + # Invalid syntax + ConfigBase.logger.error( + 'Undetectable apprise configuration found ' + 'based on line {}.'.format(line)) + # Take an early exit + return None + + # Attempt to detect configuration + if result.group('yaml'): + config_format = ConfigFormat.YAML + ConfigBase.logger.debug( + 'Detected YAML configuration ' + 'based on line {}.'.format(line)) + break + + elif result.group('text'): + config_format = ConfigFormat.TEXT + ConfigBase.logger.debug( + 'Detected TEXT configuration ' + 'based on line {}.'.format(line)) + break + + # If we reach here, we have a comment entry + # Adjust default format to TEXT + config_format = ConfigFormat.TEXT + + return config_format + + @staticmethod + def config_parse(content, asset=None, config_format=None, **kwargs): + """ + Takes the specified config content and loads it based on the specified + config_format. If a format isn't specified, then it is auto detected. + + """ + + if config_format is None: + # Detect the format + config_format = ConfigBase.detect_config_format(content) + + if not config_format: + # We couldn't detect configuration + ConfigBase.logger.error('Could not detect configuration') + return list() + + if config_format not in CONFIG_FORMATS: + # Invalid configuration type specified + ConfigBase.logger.error( + 'An invalid configuration format ({}) was specified'.format( + config_format)) + return list() + + # Dynamically load our parse_ function based on our config format + fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format)) + + # Execute our config parse function which always returns a list + return fn(content=content, asset=asset) + @staticmethod def config_parse_text(content, asset=None): """ @@ -270,9 +374,6 @@ class ConfigBase(URLBase): """ - # For logging, track the line number - line = 0 - response = list() # Define what a valid line should look like @@ -290,10 +391,7 @@ class ConfigBase(URLBase): ConfigBase.logger.error('Invalid apprise text data specified') return list() - for entry in content: - # Increment our line count - line += 1 - + for line, entry in enumerate(content, start=1): result = valid_line_re.match(entry) if not result: # Invalid syntax diff --git a/apprise/config/ConfigMemory.py b/apprise/config/ConfigMemory.py new file mode 100644 index 00000000..c8d49a14 --- /dev/null +++ b/apprise/config/ConfigMemory.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from .ConfigBase import ConfigBase +from ..AppriseLocale import gettext_lazy as _ + + +class ConfigMemory(ConfigBase): + """ + For information that was loaded from memory and does not + persist anywhere. + """ + + # The default descriptive name associated with the service + service_name = _('Memory') + + # The default protocol + protocol = 'memory' + + def __init__(self, content, **kwargs): + """ + Initialize Memory Object + + Memory objects just store the raw configuration in memory. There is + no external reference point. It's always considered cached. + """ + super(ConfigMemory, self).__init__(**kwargs) + + # Store our raw config into memory + self.content = content + + if self.config_format is None: + # Detect our format if possible + self.config_format = \ + ConfigMemory.detect_config_format(self.content) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + return 'memory://' + + def read(self, **kwargs): + """ + Simply return content stored into memory + """ + + return self.content + + @staticmethod + def parse_url(url): + """ + Memory objects have no parseable URL + + """ + # These URLs can not be parsed + return None diff --git a/test/test_apprise_config.py b/test/test_apprise_config.py index 56406b7e..c4b9fc32 100644 --- a/test/test_apprise_config.py +++ b/test/test_apprise_config.py @@ -278,6 +278,64 @@ def test_apprise_multi_config_entries(tmpdir): ac.server_pop(len(ac.servers()) - 1), NotifyBase) is True +def test_apprise_add_config(): + """ + API AppriseConfig.add_config() + + """ + content = """ + # A comment line over top of a URL + mailto://usera:pass@gmail.com + + # A line with mulitiple tag assignments to it + taga,tagb=gnome:// + + # Event if there is accidental leading spaces, this configuation + # is accepting of htat and will not exclude them + tagc=kde:// + + # A very poorly structured url + sns://:@/ + + # Just 1 token provided causes exception + sns://T1JJ3T3L2/ + """ + # Create ourselves a config object + ac = AppriseConfig() + assert ac.add_config(content=content) + + # One configuration file should have been found + assert len(ac) == 1 + + # Object can be directly checked as a boolean; response is True + # when there is at least one entry + assert ac + + # We should be able to read our 3 servers from that + assert len(ac.servers()) == 3 + + # Get our URL back + assert isinstance(ac[0].url(), six.string_types) + + # Test invalid content + assert ac.add_config(content=object()) is False + assert ac.add_config(content=42) is False + assert ac.add_config(content=None) is False + + # Still only one server loaded + assert len(ac) == 1 + + # Test having a pre-defined asset object and tag created + assert ac.add_config( + content=content, asset=AppriseAsset(), tag='a') is True + + # Now there are 2 servers loaded + assert len(ac) == 2 + + # and 6 urls.. (as we've doubled up) + assert len(ac.servers()) == 6 + + def test_apprise_config_tagging(tmpdir): """ API: AppriseConfig tagging diff --git a/test/test_config_base.py b/test/test_config_base.py index 6f6282b8..94ea5e38 100644 --- a/test/test_config_base.py +++ b/test/test_config_base.py @@ -29,6 +29,7 @@ import pytest from apprise.AppriseAsset import AppriseAsset from apprise.config.ConfigBase import ConfigBase from apprise.config import __load_matrix +from apprise import ConfigFormat # Disable logging for a cleaner testing output import logging @@ -95,6 +96,110 @@ def test_config_base(): assert results['qsd'].get('format') == 'invalid' +def test_config_base_detect_config_format(): + """ + API: ConfigBase.detect_config_format + + """ + + # Garbage Handling + assert ConfigBase.detect_config_format(object()) is None + assert ConfigBase.detect_config_format(None) is None + assert ConfigBase.detect_config_format(12) is None + + # Empty files are valid + assert ConfigBase.detect_config_format('') is ConfigFormat.TEXT + + # Valid Text Configuration + assert ConfigBase.detect_config_format(""" + # A comment line over top of a URL + mailto://userb:pass@gmail.com + """) is ConfigFormat.TEXT + + # A text file that has semi-colon as comment characters + # is valid too + assert ConfigBase.detect_config_format(""" + ; A comment line over top of a URL + mailto://userb:pass@gmail.com + """) is ConfigFormat.TEXT + + # Valid YAML Configuration + assert ConfigBase.detect_config_format(""" + # A comment line over top of a URL + version: 1 + """) is ConfigFormat.YAML + + # Just a whole lot of blank lines... + assert ConfigBase.detect_config_format('\n\n\n') is ConfigFormat.TEXT + + # Invalid Config + assert ConfigBase.detect_config_format("3") is None + + +def test_config_base_config_parse(): + """ + API: ConfigBase.config_parse + + """ + + # Garbage Handling + assert isinstance(ConfigBase.config_parse(object()), list) + assert isinstance(ConfigBase.config_parse(None), list) + assert isinstance(ConfigBase.config_parse(''), list) + assert isinstance(ConfigBase.config_parse(12), list) + + # Valid Text Configuration + result = ConfigBase.config_parse(""" + # A comment line over top of a URL + mailto://userb:pass@gmail.com + """, asset=AppriseAsset()) + # We expect to parse 1 entry from the above + assert isinstance(result, list) + assert len(result) == 1 + assert len(result[0].tags) == 0 + + # Valid Configuration + result = ConfigBase.config_parse(""" +# if no version is specified then version 1 is presumed +version: 1 + +# +# Define your notification urls: +# +urls: + - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b + - mailto://test:password@gmail.com + - syslog://: + - tag: devops, admin + """, asset=AppriseAsset()) + + # We expect to parse 3 entries from the above + assert isinstance(result, list) + assert len(result) == 3 + assert len(result[0].tags) == 0 + assert len(result[1].tags) == 0 + assert len(result[2].tags) == 2 + + # Test case where we pass in a bad format + result = ConfigBase.config_parse(""" + ; A comment line over top of a URL + mailto://userb:pass@gmail.com + """, config_format='invalid-format') + + # This is not parseable despite the valid text + assert isinstance(result, list) + assert len(result) == 0 + + result = ConfigBase.config_parse(""" + ; A comment line over top of a URL + mailto://userb:pass@gmail.com + """, config_format=ConfigFormat.TEXT) + + # Parseable + assert isinstance(result, list) + assert len(result) == 1 + + def test_config_base_config_parse_text(): """ API: ConfigBase.config_parse_text object diff --git a/test/test_config_memory.py b/test/test_config_memory.py new file mode 100644 index 00000000..0bc84e8a --- /dev/null +++ b/test/test_config_memory.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import six +from apprise.config.ConfigMemory import ConfigMemory + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + + +def test_config_memory(): + """ + API: ConfigMemory() object + + """ + + assert ConfigMemory.parse_url('garbage://') is None + + # Initialize our object + cm = ConfigMemory(content="syslog://", format='text') + + # one entry added + assert len(cm) == 1 + + # Test general functions + assert isinstance(cm.url(), six.string_types) is True + assert isinstance(cm.read(), six.string_types) is True + + # Test situation where an auto-detect is required: + cm = ConfigMemory(content="syslog://") + + # one entry added + assert len(cm) == 1 + + # Test general functions + assert isinstance(cm.url(), six.string_types) is True + assert isinstance(cm.read(), six.string_types) is True + + # Test situation where we can not detect the data + assert len(ConfigMemory(content="garbage")) == 0