markusressel/container-app-conf

View on GitHub
container_app_conf/__init__.py

Summary

Maintainability
A
1 hr
Test Coverage
#  Copyright (c) 2019 Markus Ressel
#  .
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#  .
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
#  SOFTWARE.
import copy
import logging
from typing import Dict, List

from container_app_conf.const import DEFAULT_CONFIG_FILE_PATHS
from container_app_conf.entry import ConfigEntry
from container_app_conf.formatter import ConfigFormatter, SimpleFormatter
from container_app_conf.source import DataSource
from container_app_conf.util import find_duplicates, generate_reference_config, config_entries_to_dict

LOGGER = logging.getLogger(__name__)


class ConfigBase:
    """
    Config base class.
    Extend this in your application.
    """

    _instances = {}

    def __new__(cls, data_sources: List[DataSource] = None,
                validate: bool = True,
                singleton: bool = True):
        """
        Creates a config object and reads configuration.
        :param data_sources: list of data sources to use. The first value that holds a value for a specific
                             config entry overshadows other data sources.
        :param validate: if validation should be run (can be disabled for tests)
        :param singleton: if the returned instance should be a singleton
        """
        if singleton:
            if cls._instances.get(cls, None) is None:
                instance = super(ConfigBase, cls).__new__(cls)
                cls._instances[cls] = instance
            else:
                instance = cls._instances[cls]
        else:
            instance = super(ConfigBase, cls).__new__(cls)

        self = instance
        self._config_entries = self._find_config_entries()

        if not singleton:
            # copy class attribute to instance to overshadow class attributes
            instance_attributes = {}
            for name, attribute in self._config_entries.items():
                attribute_copy = copy.deepcopy(attribute)
                self.__dict__.setdefault(name, attribute_copy)
                key = "_".join(attribute.key_path)
                instance_attributes[key] = attribute_copy
            # update config_entries list to reflect instance attributes
            self._config_entries = instance_attributes

        if data_sources is None:
            # set default data sources
            from container_app_conf.source.env_source import EnvSource
            from container_app_conf.source.yaml_source import YamlSource

            self.data_sources = [
                EnvSource(),
                YamlSource(cls.__name__)
            ]
        else:
            self.data_sources = data_sources

        self.load_config(validate)

        return instance

    def load_config(self, validate: bool):
        """
        Loads the configuration from all available sources
        """
        for source in reversed(self.data_sources):
            source.load()
            for entry in self._config_entries.values():
                if source.has(entry):
                    entry.value = source.get(entry)

        if validate:
            self.validate()

    def validate(self):
        """
        Validates the current configuration and throws an exception if something is wrong
        """
        # reset all entries to make sure None constraints are fulfilled
        for entry in self._config_entries.values():
            entry.value = entry.value

    def print(self, formatter: ConfigFormatter = None) -> str:
        """
        Prints
        :return: printable description of the current configuration
        """
        if formatter is None:
            formatter = SimpleFormatter()

        data = config_entries_to_dict(list(self._config_entries.values()), hide_secrets=True)
        output = formatter.format(data)
        return output

    def _find_config_entries(self) -> Dict[str, ConfigEntry]:
        """
        Detects config entry constants in this class
        :return: list of config entries
        """
        entries = {}
        for name, attribute in dict(map(lambda x: (x, getattr(self, x)), dir(self))).items():
            if isinstance(attribute, ConfigEntry):
                entries[name] = attribute

        entry_env_keys = list(map(lambda x: "->".join(x.key_path), entries.values()))
        duplicates = find_duplicates(entry_env_keys)
        if len(duplicates) > 0:
            clashing = ", ".join(duplicates.keys())
            raise ValueError("Key paths must be unique! Clashing paths: {}".format(clashing))

        return entries