<reponame>OSAlt/nya-chan-bot<filename>bot/config.py<gh_stars>1-10
"""
    Please check out the original file over at Python Discord's repo:
    https://github.com/python-discord/bot/blob/master/bot/constants.py

    Loads bot configuration from YAML files.
    By default, this simply loads the default
    configuration located at `config-default.yml`.
    If a file called `config.yml` is found in the
    project directory, the default configuration
    is recursively updated with any settings from
    the custom configuration. Any settings left
    out in the custom user configuration will stay
    their default values from `config-default.yml`.
"""

import logging
import os
from collections.abc import Mapping
from pathlib import Path

import yaml

__all__ = ("Bot", "Config", "Logging", "Database")

log = logging.getLogger(__name__)


def _env_var_constructor(loader, node):
    """
    Implements a custom YAML tag for loading optional environment
    variables. If the environment variable is set, returns the
    value of it. Otherwise, returns `None`.

    Example usage in the YAML configuration:

        # Optional app configuration. Set `MY_APP_KEY` in the environment to use it.
        application:
            key: !ENV 'MY_APP_KEY'
    """

    default = None

    # Check if the node is a plain string value
    if node.id == 'scalar':
        value = loader.construct_scalar(node)
        key = str(value)
    else:
        # The node value is a list
        value = loader.construct_sequence(node)

        if len(value) >= 2:
            # If we have at least two values, then we have both a key and a default value
            default = value[1]
            key = value[0]
        else:
            # Otherwise, we just have a key
            key = value[0]

    return os.getenv(key, default)


yaml.SafeLoader.add_constructor("!ENV", _env_var_constructor)


with open("config-default.yml", encoding="UTF-8") as f:
    _CONFIG_YAML = yaml.safe_load(f)


def _recursive_update(original, new):
    """
    Helper method which implements a recursive `dict.update`
    method, used for updating the original configuration with
    configuration specified by the user.
    """

    for key, value in original.items():
        if key not in new:
            continue

        if isinstance(value, Mapping):
            if not any(isinstance(subvalue, Mapping) for subvalue in value.values()):
                original[key].update(new[key])
            _recursive_update(original[key], new[key])
        else:
            original[key] = new[key]


if Path("config.yml").exists():
    log.info("Found `config.yml` file, loading constants from it.")
    with open("config.yml", encoding="UTF-8") as f:
        user_config = yaml.safe_load(f)
    _recursive_update(_CONFIG_YAML, user_config)


def check_required_keys(keys):
    """
    Verifies that keys that are set to be required are present in the
    loaded configuration.
    """
    for key_path in keys:
        lookup = _CONFIG_YAML
        try:
            for key in key_path.split('.'):
                lookup = lookup[key]
                if lookup is None:
                    raise KeyError(key)
        except KeyError:
            log.critical(
                f"A configuration for `{key_path}` is required, but was not found. "
                "Please set it in `config.yml` or setup an environment variable and try again."
            )
            raise


try:
    required_keys = _CONFIG_YAML['config']['required_keys']
except KeyError:
    pass
else:
    check_required_keys(required_keys)


class YAMLGetter(type):
    """
    Implements a custom metaclass used for accessing
    configuration data by simply accessing class attributes.
    Supports getting configuration from up to two levels
    of nested configuration through `section`.

    `section` specifies the YAML configuration section (or "key")
    in which the configuration lives, and must be set.

    Example Usage:

        # config.yml
        bot:
            prefixes:
                direct_message: ''
                guild: '!'

        # config.py
        class Prefixes(metaclass=YAMLGetter):
            section = "bot"

        # Usage in Python code
        from config import Prefixes
        def get_prefix(bot, message):
            if isinstance(message.channel, PrivateChannel):
                return Prefixes.direct_message
            return Prefixes.guild
    """

    def __new__(cls, *args, **kwargs):
        instance = super().__new__(cls, *args, *kwargs)

        for item in instance.__dict__.values():
            if type(item) == YAMLGetter:
                item.section = f"{instance.section}.{item.section}"

        return instance

    def __getattr__(cls, name):
        name = name.lower()

        try:
            section_split = cls.section.split('.')
            section = _CONFIG_YAML[section_split.pop(0)]
            if section_split:
                for key in section_split:
                    section = section[key]
            return section[name]
        except KeyError:
            dotted_path = f"{cls.section}.{name}"
            log.critical(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.")
            raise

    def __getitem__(cls, name):
        return cls.__getattr__(name)


class Config(metaclass=YAMLGetter):
    section = "config"

    debug: bool
    triggers: list


class Bot(metaclass=YAMLGetter):
    section = "bot"

    name: str
    description: str
    token: str

    prefix: str
    cogs: list


class Database(metaclass=YAMLGetter):
    section = "database"

    host: str
    port: int
    database: str

    class User(metaclass=YAMLGetter):
        section = "user"

        name: str
        password: str


class Logging(metaclass=YAMLGetter):
    section = "config.logs"

    chat: bool
    guild_entry: bool

    class Command(metaclass=YAMLGetter):
        section = "commands"

        execute: bool
        error: bool
        complete: bool
