This commit is contained in:
Ilya Bezrukov 2024-02-13 19:42:17 +03:00
commit c87b0b7d16
6 changed files with 108 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
venv
__pycache__
.idea

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# kissconfig
Simple configuration based on dataclasses and typing

21
kissconfig/__init__.py Normal file
View File

@ -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)

46
kissconfig/config.py Normal file
View File

@ -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)

34
kissconfig/loader.py Normal file
View File

@ -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)

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
pyyaml