ssokolow/quicktile

View on GitHub
quicktile/config.py

Summary

Maintainability
B
4 hrs
Test Coverage
"""Configuration parsing code"""

import logging, os
from configparser import ConfigParser

from typing import Dict, Union

#: Location for config files (determined at runtime).
XDG_CONFIG_DIR = os.environ.get('XDG_CONFIG_HOME',
                                os.path.expanduser('~/.config'))

#: MyPy type alias for fields loaded from config files
CfgDict = Dict[str, Union[str, int, float, bool, None]]  # pylint:disable=C0103

#: Default content for the configuration file
#:
#: .. todo:: Figure out a way to show :data:`DEFAULTS` documentation but with
#:    the structure pretty-printed.
DEFAULTS: Dict[str, CfgDict] = {
    'general': {
        # Use Ctrl+Alt as the default base for key combinations
        'ModMask': '<Ctrl><Alt>',
        'MovementsWrap': True,
        'ColumnCount': 3,
        'MarginX_Percent': 0,
        'MarginY_Percent': 0,
    },
    'keys': {
        "KP_Enter": "monitor-switch",
        "KP_0": "maximize",
        "KP_1": "bottom-left",
        "KP_2": "bottom",
        "KP_3": "bottom-right",
        "KP_4": "left",
        "KP_5": "center",
        "KP_6": "right",
        "KP_7": "top-left",
        "KP_8": "top",
        "KP_9": "top-right",
        "<Shift>KP_1": "move-to-bottom-left",
        "<Shift>KP_2": "move-to-bottom",
        "<Shift>KP_3": "move-to-bottom-right",
        "<Shift>KP_4": "move-to-left",
        "<Shift>KP_5": "move-to-center",
        "<Shift>KP_6": "move-to-right",
        "<Shift>KP_7": "move-to-top-left",
        "<Shift>KP_8": "move-to-top",
        "<Shift>KP_9": "move-to-top-right",
        "V": "vertical-maximize",
        "H": "horizontal-maximize",
        "C": "move-to-center",
    }
}

#: Used for resolving certain keysyms
#:
#: .. todo:: Figure out how to replace :data:`KEYLOOKUP` with a fallback that
#:      uses something in `Gtk <http://lazka.github.io/pgi-docs/Gtk-3.0/>`_ or
#:      ``python-xlib`` to look up the keysym from the character it types.
KEYLOOKUP = {
    ',': 'comma',
    '.': 'period',
    '+': 'plus',
    '-': 'minus',
}


def load_config(path) -> ConfigParser:
    """Load the config file from the given path, applying fixes as needed.
    If it does not exist, create it from the configuration defaults.

    :param path: The path to load or initialize.

    :raises TypeError: Raised if the keys or values in the :ref:`[keys]`
        section of the configuration file or what they resolve to via
        :any:`KEYLOOKUP` are not :any:`str` instances.

    .. todo:: Refactor config parsing. It's an ugly blob.
    """
    first_run = not os.path.exists(path)

    config = ConfigParser(interpolation=None)

    # Make keys case-sensitive because keysyms must be
    #
    # (``type: ignore`` to squash a false positive for something the Python 3.x
    # documentation specifically *recommends* over using RawConfigParser)
    config.optionxform = str  # type: ignore

    config.read(path)
    dirty = False

    if not config.has_section('general'):
        config.add_section('general')
        # Change this if you make backwards-incompatible changes to the
        # section and key naming in the config file.
        config.set('general', 'cfg_schema', '1')
        dirty = True

    # Transparently update the config to add missing keys
    for key, val in DEFAULTS['general'].items():
        if not config.has_option('general', key):
            config.set('general', key, str(val))
            dirty = True

    mk_raw = config.get('general', 'ModMask')
    modkeys = mk_raw.strip()  # pylint: disable=E1101
    if ' ' in modkeys and '<' not in modkeys:
        modkeys = '<%s>' % '><'.join(modkeys.split())
        logging.info("Updating modkeys format:\n %r --> %r", mk_raw, modkeys)
        config.set('general', 'ModMask', modkeys)
        dirty = True

    # Either load the keybindings or use and save the defaults
    if config.has_section('keys'):
        keymap: CfgDict = dict(config.items('keys'))
    else:
        keymap = DEFAULTS['keys']
        config.add_section('keys')
        for key, cmd in keymap.items():
            if not isinstance(key, str):  # pragma: nobranch
                raise TypeError(  # pragma: nocover
                    "Hotkey name must be a str: {!r}".format(key))
            if not isinstance(cmd, str):  # pragma: nobranch
                raise TypeError(  # pragma: nocover
                    "Command name must be a str: {!r}".format(cmd))
            config.set('keys', key, cmd)
        dirty = True

    # Migrate from the deprecated syntax for punctuation keysyms
    for key in keymap:
        # Look up unrecognized shortkeys in a hardcoded dict and
        # replace with valid names like ',' -> 'comma'
        if key in KEYLOOKUP:
            cmd = keymap[key]
            if not isinstance(cmd, str):  # pragma: nobranch
                raise TypeError(  # pragma: nocover
                    "Command name must be a str: {!r}".format(cmd))

            logging.warning("Updating config file from deprecated keybind "
                "syntax:\n\t%r --> %r", key, KEYLOOKUP[key])
            config.remove_option('keys', key)
            config.set('keys', KEYLOOKUP[key], cmd)
            dirty = True

    # Automatically update the old 'middle' command to 'center'
    for key in keymap:
        if keymap[key] == 'middle':
            keymap[key] = cmd = 'center'
            logging.warning("Updating old command in config file:"
                    "\n\tmiddle --> center")
            config.set('keys', key, cmd)
            dirty = True

    if dirty:
        with open(path, 'w') as cfg_file:
            config.write(cfg_file)
        if first_run:
            logging.info("Wrote default config file to %s", path)

    return config