From e71aa06a9a1ea1e58aa192353622d40791f751d4 Mon Sep 17 00:00:00 2001 From: romangraef Date: Wed, 11 Jul 2018 15:35:46 +0200 Subject: cleaning up docs + type hints --- .gitignore | 1 + configlib/__init__.py | 9 ++++-- configlib/model.py | 85 +++++++++++++++++++++++++++++++++++++++++-------- configlib/model_impl.py | 58 +++++++++++++++++++++++++++------ configlib/util.py | 22 +++++++++++++ configlib/version.py | 15 +++++++-- pylintrc.cfg | 2 ++ requirements-dev.txt | 4 +++ setup.cfg | 5 +++ setup.py | 11 +++++-- tests/__init__.py | 6 ---- 11 files changed, 182 insertions(+), 36 deletions(-) create mode 100644 pylintrc.cfg create mode 100644 requirements-dev.txt create mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index 4d93ca8..38914ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ configlib.egg-info venv/ +.pytest_cache/ diff --git a/configlib/__init__.py b/configlib/__init__.py index c0f25aa..371062f 100644 --- a/configlib/__init__.py +++ b/configlib/__init__.py @@ -1,6 +1,11 @@ +""" +An easy python config library. + +""" + from .model import Config, ConfigValueMissingException, InvalidConfigTypingException from .model_impl import BaseConfig -from .version import VersionInfo, version +from .version import VersionInfo, VERSION __all__ = ['ConfigValueMissingException', 'Config', 'InvalidConfigTypingException', - 'BaseConfig', 'VersionInfo', 'version'] + 'BaseConfig', 'VersionInfo', 'VERSION'] diff --git a/configlib/model.py b/configlib/model.py index 6d79464..5f5c694 100644 --- a/configlib/model.py +++ b/configlib/model.py @@ -1,41 +1,100 @@ +""" +Abstract models and classes for the config + +""" + import json +import os from abc import abstractmethod, ABC -from typing import TypeVar, Type, Union, AnyStr, TextIO +from typing import Type, Union, AnyStr, TextIO -_T = TypeVar('_T', bound='Config') +from configlib.util import snake_case class InvalidConfigTypingException(Exception): - pass + """ + The typing found in the given class is missing arguments. + Example: + + >>> import typing + >>> someting: typing.List[str, int] + + is illegal since :class:`typing.List` only takes one argument. + """ class ConfigValueMissingException(Exception): - pass + """ + The given config file is missing an argument + """ class Config(ABC): + """ + Base class for a Config. Do NOT extend this. use :class:`configlib.BaseConfig` instead. + + """ @classmethod @abstractmethod def get_name(cls) -> str: - pass + """ + Get the name for a config + + :return: the name + """ @classmethod @abstractmethod - def parse_dict(cls: Type[_T], data: dict) -> _T: - pass + def parse_dict(cls: Type['Config'], data: dict) -> 'Config': + """ + For the given data return the config instance. + + :param data: the loaded data dict + :return: a loaded config + """ @classmethod - def load(cls: Type[_T], file: Union[AnyStr, TextIO]) -> _T: + def load(cls: Type['Config'], file: Union[AnyStr, TextIO]) -> 'Config': + """ + Load a specified config file + + :param file: the file object or file path + :return: the parsed config according to :func:`.parse_dict` + """ if hasattr(file, 'read'): return cls.loads(file.read()) - with open(file) as fp: - return cls.load(fp) + with open(file) as file_pointer: + return cls.load(file_pointer) @classmethod - def loads(cls: Type[_T], text: str) -> _T: + def loads(cls: Type['Config'], text: str) -> 'Config': + """ + Load data from text + + :param text: the text data + :return: the parsed config + """ return cls.parse_dict(json.loads(text)) @classmethod - def get_instance(cls: Type[_T]) -> _T: - return cls + def get_instance(cls: Type['Config']) -> 'Config': + """ + get a Config instance according to the matching environment variable + + :return: the parsed config + """ + name = os.environ.get(snake_case(cls.get_name()), '').strip() + return cls.get_instance_for_env(name) + + @classmethod + def get_instance_for_env(cls: Type['Config'], env: str) -> 'Config': + """ + get a Config instance for a given environment + + :param env: the wanted environment + :return: the parsed config + """ + if env: + env = '-' + env + return cls.load('config/' + snake_case(cls.get_name()) + env + '.json') diff --git a/configlib/model_impl.py b/configlib/model_impl.py index 4b7a866..dc8cde0 100644 --- a/configlib/model_impl.py +++ b/configlib/model_impl.py @@ -1,21 +1,37 @@ -from typing import TypeVar, Type, Dict, List +""" +Implementations for the modules of :module:`configlib.model` +""" + +from typing import Type, Dict, List from .model import Config, ConfigValueMissingException, InvalidConfigTypingException from .util import snake_case -_T = TypeVar('_T', bound=Config) - -_T0 = TypeVar('_T0') +def parse_list_impl(cls: Type[object], data, path='') -> list: + """ + parse a list and reinterpret the individual elements according to `cls` -def parse_list_impl(cls: Type[_T0], data, path=''): + :param cls: the type of each list element + :param data: the actual list elements + :param path: the path inside the config. used for error reporting + :return: the list with transformed elements. + """ lis = [] for i, item in enumerate(data): - list.append(parse_single_item(cls, item, path + '[' + str(i) + ']')) + lis.append(parse_single_item(cls, item, path + '[' + str(i) + ']')) return lis -def parse_obj_impl(cls: Type[_T0], data, path='Config') -> _T0: +def parse_obj_impl(cls: Type[object], data, path='Config') -> object: + """ + parse a dict into an object according to `cls` + + :param cls: the type of the resulting object + :param data: the dict object + :param path: the path inside the config. used for error reporting + :return: the parsed object + """ obj = cls() annotations: Dict[str, Type] = obj.__annotations__ for key, val_type in annotations.items(): @@ -26,14 +42,31 @@ def parse_obj_impl(cls: Type[_T0], data, path='Config') -> _T0: return obj -def parse_dict_impl(val_type: Type[_T0], val, path) -> _T0: +def parse_dict_impl(val_type: Type[object], val, path) -> dict: + """ + Parse a dict and reinterpret the values according to `val_type` + + :param val_type: the type of the values of the dict + :param val: the actual dict to be reinterpreted + :param path: the path inside the config. used for error reporting + :return: the reinterpreted dict + """ dic = {} for key, value in val.items(): dic[key] = parse_single_item(val_type, value, path + '[' + repr(key) + ']') return dic -def parse_single_item(val_type: Type[_T0], val, path) -> _T0: +# noinspection PyUnresolvedReferences +def parse_single_item(val_type: Type[object], val, path): + """ + dynamically parse an item into a dict, list, object or a primitive depending on `val_type` + + :param val_type: the type to be discussed + :param val: the value to be parsed + :param path: the path inside the config. used for error reporting + :return: the parsed something + """ if issubclass(val_type, (str, int, float)): return val if isinstance(val_type, List): @@ -50,10 +83,15 @@ def parse_single_item(val_type: Type[_T0], val, path) -> _T0: class BaseConfig(Config): + """ + A :class:`Config` implementation using type hints for parsing. + """ + @classmethod def get_name(cls) -> str: return snake_case(cls.__name__).upper() @classmethod - def parse_dict(cls: Type[_T], data: dict) -> _T: + def parse_dict(cls: Type['BaseConfig'], data: dict) -> 'BaseConfig': + # noinspection PyTypeChecker return parse_obj_impl(cls, data) diff --git a/configlib/util.py b/configlib/util.py index 3c7618d..f325f25 100644 --- a/configlib/util.py +++ b/configlib/util.py @@ -1,8 +1,18 @@ +""" +Utility methods. Should not be imported from outside of :module:`configlib` +""" + import re from typing import List def parse_case(any_case: str) -> List[str]: + """ + parses a multiword string from cases like PascalCase or snake_case + + :param any_case: the multi-word string + :return: the words lowercased as an array + """ if '_' in any_case: return any_case.lower().split('_') if '-' in any_case: @@ -11,8 +21,20 @@ def parse_case(any_case: str) -> List[str]: def snake_case(any_case: str) -> str: + """ + parses a multiword string from cases like PascalCase or snake_case + + :param any_case: the multi-word string + :return: the words in snake_case + """ return '_'.join(parse_case(any_case)) def pascal_case(any_case: str) -> str: + """ + parses a multiword string from cases like PascalCase or snake_case + + :param any_case: the multi-word string + :return: the words in PascalCase + """ return ''.join(word.capitalize() for word in parse_case(any_case)) diff --git a/configlib/version.py b/configlib/version.py index 475dc6e..2c6d4a3 100644 --- a/configlib/version.py +++ b/configlib/version.py @@ -1,16 +1,25 @@ -from dataclasses import dataclass +"""versioninfo""" -@dataclass +# pylint: disable=too-few-public-methods class VersionInfo: + """Version info dataclass""" major: int minor: int build: int level: str serial: int + # pylint: disable=too-many-arguments + def __init__(self, major: int, minor: int, build: int, level: str, serial: int): + self.major: int = major + self.minor: int = minor + self.build: int = build + self.level: str = level + self.serial: int = serial + def __str__(self): return '{major}.{minor}.{build}{level}{serial}'.format(**self.__dict__) -version = VersionInfo(1, 0, 0, 'a', 0) +VERSION = VersionInfo(1, 0, 0, 'a', 0) diff --git a/pylintrc.cfg b/pylintrc.cfg new file mode 100644 index 0000000..facac26 --- /dev/null +++ b/pylintrc.cfg @@ -0,0 +1,2 @@ +[MASTER] +ignore=tests, setup.py diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..da0d5b1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +pylint +pytest +pytest-runner +pytest-pylint diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..bdd3e25 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[aliases] +test = pytest + +[tool:pytest] +addopts = --pylint --pylint-rcfile=pylintrc.cfg diff --git a/setup.py b/setup.py index c7445fd..27cb9c7 100644 --- a/setup.py +++ b/setup.py @@ -5,11 +5,14 @@ find_packages with open('configlib/version.py') as f: _loc, _glob = {}, {} exec(f.read(), _loc, _glob) - version = {**_loc, **_glob}['version'] + version = {**_loc, **_glob}['VERSION'] with open('requirements.txt') as f: requirements = f.read().splitlines() +with open('requirements-dev.txt') as f: + dev_requirements = f.read().splitlines() + with open('README.md') as f: readme = f.read() @@ -23,8 +26,12 @@ setup( version=str(version), install_requires=requirements, long_description=readme, - test_suite='tests.all_tests', + setup_requires=['pytest-runner', 'pytest-pylint'], + tests_require=['pytest', 'pylint'], license="MIT", + extras_require={ + 'dev': dev_requirements, + }, packages=['configlib'], description="An easy python config manager", classifiers=[ diff --git a/tests/__init__.py b/tests/__init__.py index e8b0db2..8b13789 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1 @@ -import unittest - -def all_tests(): - test_loader = unittest.TestLoader() - test_suite = test_loader.discover('tests', pattern='test_*.py') - return test_suite -- cgit