summaryrefslogtreecommitdiffstats
path: root/deluge/config.py
diff options
context:
space:
mode:
Diffstat (limited to 'deluge/config.py')
-rw-r--r--deluge/config.py569
1 files changed, 569 insertions, 0 deletions
diff --git a/deluge/config.py b/deluge/config.py
new file mode 100644
index 0000000..c852996
--- /dev/null
+++ b/deluge/config.py
@@ -0,0 +1,569 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2008 Andrew Resch <andrewresch@gmail.com>
+#
+# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
+# the additional special exception to link portions of this program with the OpenSSL library.
+# See LICENSE for more details.
+#
+
+"""
+Deluge Config Module
+
+This module is used for loading and saving of configuration files.. or anything
+really.
+
+The format of the config file is two json encoded dicts:
+
+<version dict>
+<content dict>
+
+The version dict contains two keys: file and format. The format version is
+controlled by the Config class. It should only be changed when anything below
+it is changed directly by the Config class. An example of this would be if we
+changed the serializer for the content to something different.
+
+The config file version is changed by the 'owner' of the config file. This is
+to signify that there is a change in the naming of some config keys or something
+similar along those lines.
+
+The content is simply the dict to be saved and will be serialized before being
+written.
+
+Converting
+
+Since the format of the config could change, there needs to be a way to have
+the Config object convert to newer formats. To do this, you will need to
+register conversion functions for various versions of the config file. Note that
+this can only be done for the 'config file version' and not for the 'format'
+version as this will be done internally.
+
+"""
+from __future__ import unicode_literals
+
+import json
+import logging
+import os
+import shutil
+from codecs import getwriter
+from io import open
+from tempfile import NamedTemporaryFile
+
+import six.moves.cPickle as pickle # noqa: N813
+
+from deluge.common import JSON_FORMAT, get_default_config_dir
+
+log = logging.getLogger(__name__)
+callLater = None # noqa: N816 Necessary for the config tests
+
+
+def prop(func):
+ """Function decorator for defining property attributes
+
+ The decorated function is expected to return a dictionary
+ containing one or more of the following pairs:
+
+ fget - function for getting attribute value
+ fset - function for setting attribute value
+ fdel - function for deleting attribute
+
+ This can be conveniently constructed by the locals() builtin
+ function; see:
+ http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/205183
+ """
+ return property(doc=func.__doc__, **func())
+
+
+def find_json_objects(s):
+ """Find json objects in a string.
+
+ Args:
+ s (str): the string to find json objects in
+
+ Returns:
+ list: A list of tuples containing start and end locations of json
+ objects in string `s`. e.g. [(start, end), ...]
+
+ """
+ objects = []
+ opens = 0
+ start = s.find('{')
+ offset = start
+
+ if start < 0:
+ return []
+
+ quoted = False
+ for index, c in enumerate(s[offset:]):
+ if c == '"':
+ quoted = not quoted
+ elif quoted:
+ continue
+ elif c == '{':
+ opens += 1
+ elif c == '}':
+ opens -= 1
+ if opens == 0:
+ objects.append((start, index + offset + 1))
+ start = index + offset + 1
+
+ return objects
+
+
+class Config(object):
+ """This class is used to access/create/modify config files.
+
+ Args:
+ filename (str): The config filename.
+ defaults (dict): The default config values to insert before loading the config file.
+ config_dir (str): the path to the config directory.
+ file_version (int): The file format for the default config values when creating
+ a fresh config. This value should be increased whenever a new migration function is
+ setup to convert old config files. (default: 1)
+
+ """
+
+ def __init__(self, filename, defaults=None, config_dir=None, file_version=1):
+ self.__config = {}
+ self.__set_functions = {}
+ self.__change_callbacks = []
+
+ # These hold the version numbers and they will be set when loaded
+ self.__version = {'format': 1, 'file': file_version}
+
+ # This will get set with a reactor.callLater whenever a config option
+ # is set.
+ self._save_timer = None
+
+ if defaults:
+ for key, value in defaults.items():
+ self.set_item(key, value)
+
+ # Load the config from file in the config_dir
+ if config_dir:
+ self.__config_file = os.path.join(config_dir, filename)
+ else:
+ self.__config_file = get_default_config_dir(filename)
+
+ self.load()
+
+ def __contains__(self, item):
+ return item in self.__config
+
+ def __setitem__(self, key, value):
+ """See set_item"""
+
+ return self.set_item(key, value)
+
+ def set_item(self, key, value):
+ """Sets item 'key' to 'value' in the config dictionary.
+
+ Does not allow changing the item's type unless it is None.
+
+ If the types do not match, it will attempt to convert it to the
+ set type before raising a ValueError.
+
+ Args:
+ key (str): Item to change to change.
+ value (any): The value to change item to, must be same type as what is
+ currently in the config.
+
+ Raises:
+ ValueError: Raised when the type of value is not the same as what is
+ currently in the config and it could not convert the value.
+
+ Examples:
+ >>> config = Config('test.conf')
+ >>> config['test'] = 5
+ >>> config['test']
+ 5
+
+ """
+ if key not in self.__config:
+ self.__config[key] = value
+ log.debug('Setting key "%s" to: %s (of type: %s)', key, value, type(value))
+ return
+
+ if self.__config[key] == value:
+ return
+
+ # Change the value type if it is not None and does not match.
+ type_match = isinstance(self.__config[key], (type(None), type(value)))
+ if value is not None and not type_match:
+ try:
+ oldtype = type(self.__config[key])
+ # Don't convert to bytes as requires encoding and value will
+ # be decoded anyway.
+ if oldtype is not bytes:
+ value = oldtype(value)
+ except ValueError:
+ log.warning('Value Type "%s" invalid for key: %s', type(value), key)
+ raise
+
+ if isinstance(value, bytes):
+ value = value.decode('utf8')
+
+ log.debug('Setting key "%s" to: %s (of type: %s)', key, value, type(value))
+ self.__config[key] = value
+
+ global callLater
+ if callLater is None:
+ # Must import here and not at the top or it will throw ReactorAlreadyInstalledError
+ from twisted.internet.reactor import (
+ callLater,
+ ) # pylint: disable=redefined-outer-name
+ # Run the set_function for this key if any
+ try:
+ for func in self.__set_functions[key]:
+ callLater(0, func, key, value)
+ except KeyError:
+ pass
+ try:
+
+ def do_change_callbacks(key, value):
+ for func in self.__change_callbacks:
+ func(key, value)
+
+ callLater(0, do_change_callbacks, key, value)
+ except Exception:
+ pass
+
+ # We set the save_timer for 5 seconds if not already set
+ if not self._save_timer or not self._save_timer.active():
+ self._save_timer = callLater(5, self.save)
+
+ def __getitem__(self, key):
+ """See get_item """
+ return self.get_item(key)
+
+ def get_item(self, key):
+ """Gets the value of item 'key'.
+
+ Args:
+ key (str): The item for which you want it's value.
+
+ Returns:
+ any: The value of item 'key'.
+
+ Raises:
+ ValueError: If 'key' is not in the config dictionary.
+
+ Examples:
+ >>> config = Config('test.conf', defaults={'test': 5})
+ >>> config['test']
+ 5
+
+ """
+ return self.__config[key]
+
+ def get(self, key, default=None):
+ """Gets the value of item 'key' if key is in the config, else default.
+
+ If default is not given, it defaults to None, so that this method
+ never raises a KeyError.
+
+ Args:
+ key (str): the item for which you want it's value
+ default (any): the default value if key is missing
+
+ Returns:
+ any: The value of item 'key' or default.
+
+ Examples:
+ >>> config = Config('test.conf', defaults={'test': 5})
+ >>> config.get('test', 10)
+ 5
+ >>> config.get('bad_key', 10)
+ 10
+
+ """
+ try:
+ return self.get_item(key)
+ except KeyError:
+ return default
+
+ def __delitem__(self, key):
+ """
+ See
+ :meth:`del_item`
+ """
+ self.del_item(key)
+
+ def del_item(self, key):
+ """Deletes item with a specific key from the configuration.
+
+ Args:
+ key (str): The item which you wish to delete.
+
+ Raises:
+ ValueError: If 'key' is not in the config dictionary.
+
+ Examples:
+ >>> config = Config('test.conf', defaults={'test': 5})
+ >>> del config['test']
+
+ """
+
+ del self.__config[key]
+
+ global callLater
+ if callLater is None:
+ # Must import here and not at the top or it will throw ReactorAlreadyInstalledError
+ from twisted.internet.reactor import (
+ callLater,
+ ) # pylint: disable=redefined-outer-name
+
+ # We set the save_timer for 5 seconds if not already set
+ if not self._save_timer or not self._save_timer.active():
+ self._save_timer = callLater(5, self.save)
+
+ def register_change_callback(self, callback):
+ """Registers a callback function for any changed value.
+
+ Will be called when any value is changed in the config dictionary.
+
+ Args:
+ callback (func): The function to call with parameters: f(key, value).
+
+ Examples:
+ >>> config = Config('test.conf', defaults={'test': 5})
+ >>> def cb(key, value):
+ ... print key, value
+ ...
+ >>> config.register_change_callback(cb)
+
+ """
+ self.__change_callbacks.append(callback)
+
+ def register_set_function(self, key, function, apply_now=True):
+ """Register a function to be called when a config value changes.
+
+ Args:
+ key (str): The item to monitor for change.
+ function (func): The function to call when the value changes, f(key, value).
+ apply_now (bool): If True, the function will be called immediately after it's registered.
+
+ Examples:
+ >>> config = Config('test.conf', defaults={'test': 5})
+ >>> def cb(key, value):
+ ... print key, value
+ ...
+ >>> config.register_set_function('test', cb, apply_now=True)
+ test 5
+
+ """
+ log.debug('Registering function for %s key..', key)
+ if key not in self.__set_functions:
+ self.__set_functions[key] = []
+
+ self.__set_functions[key].append(function)
+
+ # Run the function now if apply_now is set
+ if apply_now:
+ function(key, self.__config[key])
+ return
+
+ def apply_all(self):
+ """Calls all set functions.
+
+ Examples:
+ >>> config = Config('test.conf', defaults={'test': 5})
+ >>> def cb(key, value):
+ ... print key, value
+ ...
+ >>> config.register_set_function('test', cb, apply_now=False)
+ >>> config.apply_all()
+ test 5
+
+ """
+ log.debug('Calling all set functions..')
+ for key, value in self.__set_functions.items():
+ for func in value:
+ func(key, self.__config[key])
+
+ def apply_set_functions(self, key):
+ """Calls set functions for `:param:key`.
+
+ Args:
+ key (str): the config key
+
+ """
+ log.debug('Calling set functions for key %s..', key)
+ if key in self.__set_functions:
+ for func in self.__set_functions[key]:
+ func(key, self.__config[key])
+
+ def load(self, filename=None):
+ """Load a config file.
+
+ Args:
+ filename (str): If None, uses filename set in object initialization
+
+ """
+ if not filename:
+ filename = self.__config_file
+
+ try:
+ with open(filename, 'r', encoding='utf8') as _file:
+ data = _file.read()
+ except IOError as ex:
+ log.warning('Unable to open config file %s: %s', filename, ex)
+ return
+
+ objects = find_json_objects(data)
+
+ if not len(objects):
+ # No json objects found, try depickling it
+ try:
+ self.__config.update(pickle.loads(data))
+ except Exception as ex:
+ log.exception(ex)
+ log.warning('Unable to load config file: %s', filename)
+ elif len(objects) == 1:
+ start, end = objects[0]
+ try:
+ self.__config.update(json.loads(data[start:end]))
+ except Exception as ex:
+ log.exception(ex)
+ log.warning('Unable to load config file: %s', filename)
+ elif len(objects) == 2:
+ try:
+ start, end = objects[0]
+ self.__version.update(json.loads(data[start:end]))
+ start, end = objects[1]
+ self.__config.update(json.loads(data[start:end]))
+ except Exception as ex:
+ log.exception(ex)
+ log.warning('Unable to load config file: %s', filename)
+
+ log.debug(
+ 'Config %s version: %s.%s loaded: %s',
+ filename,
+ self.__version['format'],
+ self.__version['file'],
+ self.__config,
+ )
+
+ def save(self, filename=None):
+ """Save configuration to disk.
+
+ Args:
+ filename (str): If None, uses filename set in object initialization
+
+ Returns:
+ bool: Whether or not the save succeeded.
+
+ """
+ if not filename:
+ filename = self.__config_file
+ # Check to see if the current config differs from the one on disk
+ # We will only write a new config file if there is a difference
+ try:
+ with open(filename, 'r', encoding='utf8') as _file:
+ data = _file.read()
+ objects = find_json_objects(data)
+ start, end = objects[0]
+ version = json.loads(data[start:end])
+ start, end = objects[1]
+ loaded_data = json.loads(data[start:end])
+ if self.__config == loaded_data and self.__version == version:
+ # The config has not changed so lets just return
+ if self._save_timer and self._save_timer.active():
+ self._save_timer.cancel()
+ return True
+ except (IOError, IndexError) as ex:
+ log.warning('Unable to open config file: %s because: %s', filename, ex)
+
+ # Save the new config and make sure it's written to disk
+ try:
+ with NamedTemporaryFile(
+ prefix=os.path.basename(filename) + '.', delete=False
+ ) as _file:
+ filename_tmp = _file.name
+ log.debug('Saving new config file %s', filename_tmp)
+ json.dump(self.__version, getwriter('utf8')(_file), **JSON_FORMAT)
+ json.dump(self.__config, getwriter('utf8')(_file), **JSON_FORMAT)
+ _file.flush()
+ os.fsync(_file.fileno())
+ except IOError as ex:
+ log.error('Error writing new config file: %s', ex)
+ return False
+
+ # Resolve symlinked config files before backing up and saving.
+ filename = os.path.realpath(filename)
+
+ # Make a backup of the old config
+ try:
+ log.debug('Backing up old config file to %s.bak', filename)
+ shutil.move(filename, filename + '.bak')
+ except IOError as ex:
+ log.warning('Unable to backup old config: %s', ex)
+
+ # The new config file has been written successfully, so let's move it over
+ # the existing one.
+ try:
+ log.debug('Moving new config file %s to %s', filename_tmp, filename)
+ shutil.move(filename_tmp, filename)
+ except IOError as ex:
+ log.error('Error moving new config file: %s', ex)
+ return False
+ else:
+ return True
+ finally:
+ if self._save_timer and self._save_timer.active():
+ self._save_timer.cancel()
+
+ def run_converter(self, input_range, output_version, func):
+ """Runs a function that will convert file versions.
+
+ Args:
+ input_range (tuple): (int, int) The range of input versions this function will accept.
+ output_version (int): The version this function will convert to.
+ func (func): The function that will do the conversion, it will take the config
+ dict as an argument and return the augmented dict.
+
+ Raises:
+ ValueError: If output_version is less than the input_range.
+
+ """
+ if output_version in input_range or output_version <= max(input_range):
+ raise ValueError('output_version needs to be greater than input_range')
+
+ if self.__version['file'] not in input_range:
+ log.debug(
+ 'File version %s is not in input_range %s, ignoring converter function..',
+ self.__version['file'],
+ input_range,
+ )
+ return
+
+ try:
+ self.__config = func(self.__config)
+ except Exception as ex:
+ log.exception(ex)
+ log.error(
+ 'There was an exception try to convert config file %s %s to %s',
+ self.__config_file,
+ self.__version['file'],
+ output_version,
+ )
+ raise ex
+ else:
+ self.__version['file'] = output_version
+ self.save()
+
+ @property
+ def config_file(self):
+ return self.__config_file
+
+ @prop
+ def config(): # pylint: disable=no-method-argument
+ """The config dictionary"""
+
+ def fget(self):
+ return self.__config
+
+ def fdel(self):
+ return self.save()
+
+ return locals()