commit c87b0b7d1631931f08d94c4ca58797f5a4b37136 Author: Ilya Bezrukov Date: Tue Feb 13 19:42:17 2024 +0300 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..329ec83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +venv +__pycache__ +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fbef80 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# kissconfig + +Simple configuration based on dataclasses and typing diff --git a/kissconfig/__init__.py b/kissconfig/__init__.py new file mode 100644 index 0000000..79d5bfd --- /dev/null +++ b/kissconfig/__init__.py @@ -0,0 +1,21 @@ +import os +import shutil +import logging +import sys +from typing import Type + +from kissconfig.config import ConfigClass +from kissconfig.loader import Loader, YamlLoader + + +def load_config(main_config: Type[ConfigClass], loader: Loader = YamlLoader()): + if not os.path.exists(config_path): + logging.warning(f"Unable to locate app config ({config_path})!") + if not os.path.exists(example_config_path): + logging.critical(f"Unable to locate example config ({example_config_path})") + sys.exit(1) + else: + shutil.copy2(example_config_path, config_path) + logging.warning(f"Actual app config (%s) created from example (%s), don't forget to update it!", + config_path, example_config_path) + return main_config.from_file(config_path, config_namespace) diff --git a/kissconfig/config.py b/kissconfig/config.py new file mode 100644 index 0000000..6361a34 --- /dev/null +++ b/kissconfig/config.py @@ -0,0 +1,46 @@ +from typing import Any, get_args, get_origin, get_type_hints +from dataclasses import is_dataclass, fields, MISSING + + +class ConfigClass: + @staticmethod + def parse(data: Any, type_hint): + origin = get_origin(type_hint) + if origin is list: + result = list() + for value in data: + result.append(ConfigClass.parse(value, get_args(type_hint)[0])) + elif origin is dict: + result = dict() + for key, value in data.items(): + result[key] = ConfigClass.parse(value, get_args(type_hint)[1]) + elif origin is None and is_dataclass(type_hint): + if issubclass(type_hint, ConfigClass): + result = type_hint.from_dict(data) + else: + raise TypeError(f"Nested dataclass {type_hint}) is not subclass of ConfigClass") + else: + result = data + return result + + @classmethod + def from_dict(cls, data: dict): + kwargs = {} + hints = get_type_hints(cls) + for f in fields(cls): + if f.name not in data: + if f.default != MISSING or f.default_factory != MISSING: + continue + raise ValueError(f"{cls.__name__}.{f.name} is not configured!") + kwargs[f.name] = ConfigClass.parse(data[f.name], hints[f.name]) + return cls(**kwargs) + + @classmethod + def from_file(cls, filepath: str, config_namespace: str = ""): + with open(filepath) as f: + data = safe_load(f) + if data is None: + raise ValueError("") + if config_namespace: + data = data.get(config_namespace, {}) + return cls.from_dict(data) diff --git a/kissconfig/loader.py b/kissconfig/loader.py new file mode 100644 index 0000000..654a112 --- /dev/null +++ b/kissconfig/loader.py @@ -0,0 +1,34 @@ +from abc import ABCMeta, abstractmethod +from typing import Type + +from yaml import safe_load + +from kissconfig.config import ConfigClass + + +class EmptyConfig (ValueError): + pass + + +class Loader (metaclass=ABCMeta): + def __init__(self, path: str = None, + example_path: str = None): + self.path = path + self.example_path = example_path + + @abstractmethod + def parse_file(self, path: str) -> dict: + pass + + def from_file(self, config_class: Type[ConfigClass], path: str) -> ConfigClass: + data = self.parse_file(path) + if not data: + raise EmptyConfig("Configuration file seems to be empty") + if self.namespace is not None: + data = data.get(self.namespace, {}) + return config_class.from_dict(data) + + +class YamlLoader (Loader): + def parse_file(self, path: str) -> dict: + return safe_load(path) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c3726e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyyaml