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