Source code for yact.config

import os
import sys
import yaml
import hashlib
import logging
from time import sleep
from threading import Lock, Thread
from datetime import datetime, timedelta

logger = logging.getLogger(__name__)


class InvalidConfigFile(Exception):
    """Raised when config cannot be parsed/opened"""
    pass


class MissingConfig(Exception):
    """Indicates the config file does not exist"""
    pass


class ConfigEditFailed(Exception):
    """
    Raised when a config edit/update is impossible
    due to mismatched data types
    """
    pass


def generate_md5sum(filename, encoding='utf-8'):
    with open(filename, 'r') as f:
        md5 = hashlib.md5(f.read().encode(encoding))
    return md5.hexdigest()


def from_file(filename, directory=None, unsafe=False, auto_reload=False, create_if_missing=False):
    """
    Convenience function to search for a config file and
    return a `Config` object. Searches some default
    locations (currently only cares about Linux systems)
    for the specified file. If a directory is passed in
    it will be checked first.

    Requesting a config file that does not exist will fail
    by default. If you really do want to create the file,
    pass the `create_if_missing` argument. The requested
    config file will be created relative to your current
    directory unless `directory` is passed, in which case
    the file will be created there.
    """
    prefixes = ['/etc', '~/.config', os.path.abspath(os.path.curdir), os.path.abspath(os.path.pardir)]
    if directory:
        prefixes.insert(0, directory)
    if os.path.isfile(filename):
        logger.debug('Retrieving config from full path {}'.format(filename))
        path = filename
    else:
        logger.debug('Searching for path to {}'.format(filename))
        for p in prefixes:
            temp = os.path.join(p, filename)
            if os.path.exists(temp) and not os.path.isdir(temp):
                logger.debug("Found {} in {}".format(filename, p))
                path = temp
                break
        else:
            if not create_if_missing:
                raise MissingConfig('{} does not exist'.format(filename))
    config = Config(filename=path, unsafe=unsafe, auto_reload=auto_reload)
    config.refresh()
    return config


[docs]class Config(object): """ The `Config` object is a wrapper around YAML data. For most use cases, the basic functionality of reading a YAML file (extension does not matter) is sufficient. While not currently tested, unsafe loading of YAML files is supported using the unsafe flag. """ def __init__(self, filename, unsafe=False, auto_reload=False): self.unsafe = unsafe self.auto_reload = auto_reload self._file_watcher = None self.filename = filename self.md5sum = None self._lock = Lock() self.ts_refreshed = None self.ts_refreshed_utc = None def start_file_watch(self, interval=5): if self._file_watcher and self._file_watcher.is_alive(): return True # No need to create a new watcher def watcher(config, interval): while True: if config.config_file_changed: config.refresh() sleep(interval) self._file_watcher = Thread(target=watcher, args=(self, interval)) self._file_watcher.setDaemon(True) self._file_watcher.start() def refresh(self): with self._lock: try: self.md5sum = generate_md5sum(self.filename) with open(self.filename, 'r') as f: if not self.unsafe: self._data = yaml.safe_load(f) else: self._data = yaml.load(f) self.ts_refreshed = datetime.now() self.ts_refreshed_utc = datetime.utcnow() except Exception as e: # TODO: Split out into handling file IO and parsing errors raise InvalidConfigFile('{} failed to load: {}'.format(self.filename, e)) if self.auto_reload is True: self.start_file_watch()
[docs] def get(self, key, default=None): """ Retrieve the value of a key (or consecutive keys joined by periods) or default, similar to dict.get """ try: return self.__getitem__(key) except KeyError: return default
[docs] def set(self, key, value): """ Set the value of a provided key (or nested keys joined by periods) to the provided value """ self.__setitem__(key, value)
[docs] def remove(self, key): """ Remove an item from configuration file Establishes lock on configuration data, deletes config entry matching the passed in key. Saves updated configuration back to file. """ with self._lock: namespace = key.split('.') data = self._data for name in namespace[:-1]: try: data = data[name] except KeyError: return # Item already gone, no need to do anything try: data.pop(namespace[-1]) except KeyError: return # Same as above self.save()
@property def config_file_changed(self): return self.md5sum != generate_md5sum(self.filename) @property def sections(self): """ Provided for users of the standard ConfigParser module. """ with self._lock: return list(self._data.keys())
[docs] def save(self): """ Save current configuration back to file in YAML format Acquires configuration lock, opens file in overwrite mode ('w') and writes the output of yaml.dump to the file object. default_flow_style is set to false to force proper YAML formatting """ with self._lock: with open(self.filename, 'w') as f: yaml.dump(self._data, f, default_flow_style=False) self.md5sum = generate_md5sum(self.filename)
def __repr__(self): return "{}({})".format(self.__class__.__name__, self.filename) def __getitem__(self, item): """ Allow `Config` to behave as a dictionary. Supports nested lookups: :: >>> print(config['db']) {'db': {'host': 'localhost', 'port': 21707}} >>> print(config['db.host']) 'localhost' """ with self._lock: namespace = item.split('.') data = self._data for name in namespace: data = data[name] # Allow keyerrors to bubble up return data def __setitem__(self, key, value): """ Enable dict-like setting of config values. Supports nested updates: :: >>> print(config) {} >>> config['db.host'] = 'localhost' >>> print(config['db']) {'db': {'host': 'localhost'}} >>> config['db.port'] = 21707 {'db': {'host': 'localhost', 'port': 21707}} """ with self._lock: namespace = key.split('.') data = self._data for name in namespace[:-1]: if hasattr(data, 'get') and hasattr(data.get(name, {}), 'get'): if data.get(name) is None: data[name] = {} else: raise ConfigEditFailed("Unable to set {}: {} is an invalid child of {}".format(key, name, data)) data = data[name] data[namespace[-1]] = value self.save()