1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
4# All rights reserved.
5#
6# This code is licensed under the MIT License.
7#
8# Permission is hereby granted, free of charge, to any person obtaining a copy
9# of this software and associated documentation files(the "Software"), to deal
10# in the Software without restriction, including without limitation the rights
11# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12# copies of the Software, and to permit persons to whom the Software is
13# furnished to do so, subject to the following conditions :
14#
15# The above copyright notice and this permission notice shall be included in
16# all copies or substantial portions of the Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24# THE SOFTWARE.
25
26import six
27
28from . import config
29from . import ConfigBase
30from . import CONFIG_FORMATS
31from . import URLBase
32from .AppriseAsset import AppriseAsset
33
34from .common import MATCH_ALL_TAG
35from .utils import GET_SCHEMA_RE
36from .utils import parse_list
37from .utils import is_exclusive_match
38from .logger import logger
39
40
41class AppriseConfig(object):
42    """
43    Our Apprise Configuration File Manager
44
45        - Supports a list of URLs defined one after another (text format)
46        - Supports a destinct YAML configuration format
47
48    """
49
50    def __init__(self, paths=None, asset=None, cache=True, recursion=0,
51                 insecure_includes=False, **kwargs):
52        """
53        Loads all of the paths specified (if any).
54
55        The path can either be a single string identifying one explicit
56        location, otherwise you can pass in a series of locations to scan
57        via a list.
58
59        If no path is specified then a default list is used.
60
61        By default we cache our responses so that subsiquent calls does not
62        cause the content to be retrieved again. Setting this to False does
63        mean more then one call can be made to retrieve the (same) data.  This
64        method can be somewhat inefficient if disabled and you're set up to
65        make remote calls.  Only disable caching if you understand the
66        consequences.
67
68        You can alternatively set the cache value to an int identifying the
69        number of seconds the previously retrieved can exist for before it
70        should be considered expired.
71
72        It's also worth nothing that the cache value is only set to elements
73        that are not already of subclass ConfigBase()
74
75        recursion defines how deep we recursively handle entries that use the
76        `import` keyword. This keyword requires us to fetch more configuration
77        from another source and add it to our existing compilation. If the
78        file we remotely retrieve also has an `import` reference, we will only
79        advance through it if recursion is set to 2 deep.  If set to zero
80        it is off.  There is no limit to how high you set this value. It would
81        be recommended to keep it low if you do intend to use it.
82
83        insecure includes by default are disabled. When set to True, all
84        Apprise Config files marked to be in STRICT mode are treated as being
85        in ALWAYS mode.
86
87        Take a file:// based configuration for example, only a file:// based
88        configuration can import another file:// based one. because it is set
89        to STRICT mode. If an http:// based configuration file attempted to
90        import a file:// one it woul fail. However this import would be
91        possible if insecure_includes is set to True.
92
93        There are cases where a self hosting apprise developer may wish to load
94        configuration from memory (in a string format) that contains import
95        entries (even file:// based ones).  In these circumstances if you want
96        these includes to be honored, this value must be set to True.
97        """
98
99        # Initialize a server list of URLs
100        self.configs = list()
101
102        # Prepare our Asset Object
103        self.asset = \
104            asset if isinstance(asset, AppriseAsset) else AppriseAsset()
105
106        # Set our cache flag
107        self.cache = cache
108
109        # Initialize our recursion value
110        self.recursion = recursion
111
112        # Initialize our insecure_includes flag
113        self.insecure_includes = insecure_includes
114
115        if paths is not None:
116            # Store our path(s)
117            self.add(paths)
118
119        return
120
121    def add(self, configs, asset=None, tag=None, cache=True, recursion=None,
122            insecure_includes=None):
123        """
124        Adds one or more config URLs into our list.
125
126        You can override the global asset if you wish by including it with the
127        config(s) that you add.
128
129        By default we cache our responses so that subsiquent calls does not
130        cause the content to be retrieved again. Setting this to False does
131        mean more then one call can be made to retrieve the (same) data.  This
132        method can be somewhat inefficient if disabled and you're set up to
133        make remote calls.  Only disable caching if you understand the
134        consequences.
135
136        You can alternatively set the cache value to an int identifying the
137        number of seconds the previously retrieved can exist for before it
138        should be considered expired.
139
140        It's also worth nothing that the cache value is only set to elements
141        that are not already of subclass ConfigBase()
142
143        Optionally override the default recursion value.
144
145        Optionally override the insecure_includes flag.
146        if insecure_includes is set to True then all plugins that are
147        set to a STRICT mode will be a treated as ALWAYS.
148        """
149
150        # Initialize our return status
151        return_status = True
152
153        # Initialize our default cache value
154        cache = cache if cache is not None else self.cache
155
156        # Initialize our default recursion value
157        recursion = recursion if recursion is not None else self.recursion
158
159        # Initialize our default insecure_includes value
160        insecure_includes = \
161            insecure_includes if insecure_includes is not None \
162            else self.insecure_includes
163
164        if asset is None:
165            # prepare default asset
166            asset = self.asset
167
168        if isinstance(configs, ConfigBase):
169            # Go ahead and just add our configuration into our list
170            self.configs.append(configs)
171            return True
172
173        elif isinstance(configs, six.string_types):
174            # Save our path
175            configs = (configs, )
176
177        elif not isinstance(configs, (tuple, set, list)):
178            logger.error(
179                'An invalid configuration path (type={}) was '
180                'specified.'.format(type(configs)))
181            return False
182
183        # Iterate over our configuration
184        for _config in configs:
185
186            if isinstance(_config, ConfigBase):
187                # Go ahead and just add our configuration into our list
188                self.configs.append(_config)
189                continue
190
191            elif not isinstance(_config, six.string_types):
192                logger.warning(
193                    "An invalid configuration (type={}) was specified.".format(
194                        type(_config)))
195                return_status = False
196                continue
197
198            logger.debug("Loading configuration: {}".format(_config))
199
200            # Instantiate ourselves an object, this function throws or
201            # returns None if it fails
202            instance = AppriseConfig.instantiate(
203                _config, asset=asset, tag=tag, cache=cache,
204                recursion=recursion, insecure_includes=insecure_includes)
205            if not isinstance(instance, ConfigBase):
206                return_status = False
207                continue
208
209            # Add our initialized plugin to our server listings
210            self.configs.append(instance)
211
212        # Return our status
213        return return_status
214
215    def add_config(self, content, asset=None, tag=None, format=None,
216                   recursion=None, insecure_includes=None):
217        """
218        Adds one configuration file in it's raw format. Content gets loaded as
219        a memory based object and only exists for the life of this
220        AppriseConfig object it was loaded into.
221
222        If you know the format ('yaml' or 'text') you can specify
223        it for slightly less overhead during this call.  Otherwise the
224        configuration is auto-detected.
225
226        Optionally override the default recursion value.
227
228        Optionally override the insecure_includes flag.
229        if insecure_includes is set to True then all plugins that are
230        set to a STRICT mode will be a treated as ALWAYS.
231        """
232
233        # Initialize our default recursion value
234        recursion = recursion if recursion is not None else self.recursion
235
236        # Initialize our default insecure_includes value
237        insecure_includes = \
238            insecure_includes if insecure_includes is not None \
239            else self.insecure_includes
240
241        if asset is None:
242            # prepare default asset
243            asset = self.asset
244
245        if not isinstance(content, six.string_types):
246            logger.warning(
247                "An invalid configuration (type={}) was specified.".format(
248                    type(content)))
249            return False
250
251        logger.debug("Loading raw configuration: {}".format(content))
252
253        # Create ourselves a ConfigMemory Object to store our configuration
254        instance = config.ConfigMemory(
255            content=content, format=format, asset=asset, tag=tag,
256            recursion=recursion, insecure_includes=insecure_includes)
257
258        if instance.config_format not in CONFIG_FORMATS:
259            logger.warning(
260                "The format of the configuration could not be deteced.")
261            return False
262
263        # Add our initialized plugin to our server listings
264        self.configs.append(instance)
265
266        # Return our status
267        return True
268
269    def servers(self, tag=MATCH_ALL_TAG, *args, **kwargs):
270        """
271        Returns all of our servers dynamically build based on parsed
272        configuration.
273
274        If a tag is specified, it applies to the configuration sources
275        themselves and not the notification services inside them.
276
277        This is for filtering the configuration files polled for
278        results.
279
280        """
281        # Build our tag setup
282        #   - top level entries are treated as an 'or'
283        #   - second level (or more) entries are treated as 'and'
284        #
285        #   examples:
286        #     tag="tagA, tagB"                = tagA or tagB
287        #     tag=['tagA', 'tagB']            = tagA or tagB
288        #     tag=[('tagA', 'tagC'), 'tagB']  = (tagA and tagC) or tagB
289        #     tag=[('tagB', 'tagC')]          = tagB and tagC
290
291        response = list()
292
293        for entry in self.configs:
294
295            # Apply our tag matching based on our defined logic
296            if is_exclusive_match(
297                    logic=tag, data=entry.tags, match_all=MATCH_ALL_TAG):
298                # Build ourselves a list of services dynamically and return the
299                # as a list
300                response.extend(entry.servers())
301
302        return response
303
304    @staticmethod
305    def instantiate(url, asset=None, tag=None, cache=None,
306                    recursion=0, insecure_includes=False,
307                    suppress_exceptions=True):
308        """
309        Returns the instance of a instantiated configuration plugin based on
310        the provided Config URL.  If the url fails to be parsed, then None
311        is returned.
312
313        """
314        # Attempt to acquire the schema at the very least to allow our
315        # configuration based urls.
316        schema = GET_SCHEMA_RE.match(url)
317        if schema is None:
318            # Plan B is to assume we're dealing with a file
319            schema = config.ConfigFile.protocol
320            url = '{}://{}'.format(schema, URLBase.quote(url))
321
322        else:
323            # Ensure our schema is always in lower case
324            schema = schema.group('schema').lower()
325
326            # Some basic validation
327            if schema not in config.SCHEMA_MAP:
328                logger.warning('Unsupported schema {}.'.format(schema))
329                return None
330
331        # Parse our url details of the server object as dictionary containing
332        # all of the information parsed from our URL
333        results = config.SCHEMA_MAP[schema].parse_url(url)
334
335        if not results:
336            # Failed to parse the server URL
337            logger.warning('Unparseable URL {}.'.format(url))
338            return None
339
340        # Build a list of tags to associate with the newly added notifications
341        results['tag'] = set(parse_list(tag))
342
343        # Prepare our Asset Object
344        results['asset'] = \
345            asset if isinstance(asset, AppriseAsset) else AppriseAsset()
346
347        if cache is not None:
348            # Force an over-ride of the cache value to what we have specified
349            results['cache'] = cache
350
351        # Recursion can never be parsed from the URL
352        results['recursion'] = recursion
353
354        # Insecure includes flag can never be parsed from the URL
355        results['insecure_includes'] = insecure_includes
356
357        if suppress_exceptions:
358            try:
359                # Attempt to create an instance of our plugin using the parsed
360                # URL information
361                cfg_plugin = config.SCHEMA_MAP[results['schema']](**results)
362
363            except Exception:
364                # the arguments are invalid or can not be used.
365                logger.warning('Could not load URL: %s' % url)
366                return None
367
368        else:
369            # Attempt to create an instance of our plugin using the parsed
370            # URL information but don't wrap it in a try catch
371            cfg_plugin = config.SCHEMA_MAP[results['schema']](**results)
372
373        return cfg_plugin
374
375    def clear(self):
376        """
377        Empties our configuration list
378
379        """
380        self.configs[:] = []
381
382    def server_pop(self, index):
383        """
384        Removes an indexed Apprise Notification from the servers
385        """
386
387        # Tracking variables
388        prev_offset = -1
389        offset = prev_offset
390
391        for entry in self.configs:
392            servers = entry.servers(cache=True)
393            if len(servers) > 0:
394                # Acquire a new maximum offset to work with
395                offset = prev_offset + len(servers)
396
397                if offset >= index:
398                    # we can pop an notification from our config stack
399                    return entry.pop(index if prev_offset == -1
400                                     else (index - prev_offset - 1))
401
402                # Update our old offset
403                prev_offset = offset
404
405        # If we reach here, then we indexed out of range
406        raise IndexError('list index out of range')
407
408    def pop(self, index=-1):
409        """
410        Removes an indexed Apprise Configuration from the stack and returns it.
411
412        By default, the last element is removed from the list
413        """
414        # Remove our entry
415        return self.configs.pop(index)
416
417    def __getitem__(self, index):
418        """
419        Returns the indexed config entry of a loaded apprise configuration
420        """
421        return self.configs[index]
422
423    def __bool__(self):
424        """
425        Allows the Apprise object to be wrapped in an Python 3.x based 'if
426        statement'.  True is returned if at least one service has been loaded.
427        """
428        return True if self.configs else False
429
430    def __nonzero__(self):
431        """
432        Allows the Apprise object to be wrapped in an Python 2.x based 'if
433        statement'.  True is returned if at least one service has been loaded.
434        """
435        return True if self.configs else False
436
437    def __iter__(self):
438        """
439        Returns an iterator to our config list
440        """
441        return iter(self.configs)
442
443    def __len__(self):
444        """
445        Returns the number of config entries loaded
446        """
447        return len(self.configs)
448