castero/config.py

Summary

Maintainability
A
1 hr
Test Coverage
B
81%
import configparser
import os
import sys

from castero.datafile import DataFile


class ConfigError(Exception):
    """An ambiguous error while handling the configuration."""


class ConfigParseError(ConfigError):
    """An error occurred while parsing the config file."""


class ConfigDuplicateError(ConfigError):
    """The config file contained a duplicate variable."""


class _Config(DataFile):
    """The user's config settings.

    Reads the configuration file. Instances of this class can generally be
    treated like dictionaries, accessing a variable with config_instance[key].

    Modifying config variables inside the application is not supported; config
    changes must be made to the config file itself.
    """

    PATH = os.path.join(DataFile.CONFIG_DIR, "castero.conf")
    DEFAULT_PATH = os.path.join(DataFile.PACKAGE, "templates/castero.conf")

    def __init__(self) -> None:
        """
        Note: the config class is a singleton.
        """
        super().__init__(self.PATH, self.DEFAULT_PATH)

        # strictly use default path when testing
        if "pytest" in sys.modules:
            self._path = self._default_path

        self.load()

    def __setitem__(self, key, value):
        pass

    def load(self) -> None:
        """Loads the config file.

        :raises ConfigParseError: an error occurred while parsing the config file
        """
        assert os.path.exists(self._path)
        assert os.path.exists(self._default_path)

        conf = configparser.ConfigParser()
        try:
            conf.read(self._path)
        except configparser.ParsingError:
            raise ConfigParseError("An error occurred while parsing the config file")

        # we also read from the the default_conf to make sure conf contains
        # all of the necessary parameters
        default_conf = configparser.ConfigParser()
        try:
            default_conf.read(self._default_path)
        except configparser.ParsingError:
            raise ConfigParseError(
                "An error occurred while parsing the default config file" " (don't modify that one!)"
            )

        # ensure that all variables in the default_conf are present
        for section in default_conf:
            if section not in conf:
                self.migrate(conf, default_conf)
                break  # we will only ever need to migrate once
            for key in default_conf[section]:
                if key not in conf[section]:
                    self.migrate(conf, default_conf)
                    break  # we will only ever need to migrate once

        for section in conf:
            for key in conf[section]:
                if section in default_conf:
                    if key in default_conf[section]:
                        # sections in the config file are purely for aesthetic
                        # purposes - this config object only stores variables
                        # at a single depth
                        if key in self.data:
                            raise ConfigDuplicateError("Variable defined multiple times, key: " + key)

                        self.data[key] = conf[section][key]
                    else:
                        # disallow keys which are not in the default config
                        self.migrate(conf, default_conf)
                else:
                    self.migrate(conf, default_conf)

    def migrate(self, conf, default_conf) -> None:
        """Migrates the user's config to have the same keys as the default.

        Generally this method will be used when the client is updated to change
        some parts of the default config.

        The migration process is rather brash -- it simply retains the user's
        current config in memory, overwrites their file with the default file,
        and then using basic parsing replaces any variables already defined
        by the user with their definition.

        :param conf ConfigParser of the user's config file
        :param default_conf ConfigParser of the default config file
        """
        # convert conf and default_conf to 1-dim dictionaries since they may
        # not have the same sections
        conf_dict = {}
        for section in conf:
            for key in conf[section]:
                conf_dict[key] = conf[section][key]
        default_conf_dict = {}
        for section in default_conf:
            for key in default_conf[section]:
                default_conf_dict[key] = default_conf[section][key]

        # In an update, we renamed key_delete to key_remove and added a
        # different key_delete value. If the user has key_delete but not
        # key_removed, we swap these values manually.
        if "key_delete" in conf_dict and "key_remove" not in conf_dict:
            conf_dict["key_remove"] = conf_dict["key_delete"]
            conf_dict["key_delete"] = default_conf_dict["key_delete"]

        with open(self._default_path, "r") as default_conf_file:
            lines = default_conf_file.readlines()
            for line in lines:
                for key in default_conf_dict:
                    if line.startswith(key + " "):
                        if key in conf_dict:
                            lines[lines.index(line)] = "%s = %s\n" % (line.split(" =")[0], conf_dict[key])

        with open(self._path, "w") as conf_file:
            for line in lines:
                conf_file.write(line)
        conf.read(self._path)


Config = _Config()