"""
A configuration reader which reads values stored in two levels of keys.
The first level is named `section` and the second level `key`.
argparse arguments (`argparse`): (You have to specify a mapping)
.. code::
mapping = {
'section.key': 'args_attribute'
}
A python dictionary (`dictonary`):
.. code:: python
{
'section': {
'key': 'value'
}
}
Environment variables (`environ`):
.. code:: shell
export prefix__section__key=value
INI file (`ini`):
.. code:: ini
[section]
key = value
"""
import abc
import argparse
import ast
import configparser
import os
import re
import typing
from typing import Any, Dict, List, Optional, TypedDict
[docs]class ConfigValueError(Exception):
"""Configuration value can’t be found."""
[docs]class IniReaderError(Exception):
"""Ini file not valid."""
[docs]def validate_key(key: str) -> bool:
""":param key: Validate the name of a section or a key."""
if re.match(r'^[a-zA-Z0-9_]+$', key):
return True
raise ValueError(
'The key “{}” contains invalid characters (allowed: a-zA-Z0-9_).'
.format(key)
)
# Reader classes ##############################################################
[docs]class ReaderBase(object, metaclass=abc.ABCMeta):
"""Base class for all readers"""
def _exception(self, msg: str):
""":raises: ConfigValueError"""
raise ConfigValueError(msg)
[docs] @abc.abstractmethod
def get(self, section: str, key: str) -> Any:
raise NotImplementedError('A reader class must have a `get` method.')
Mapping = Dict[str, str]
"""A dictionary like this one: `{'section.key': 'dest'}`.
`dest` is the property name of the `args` object."""
[docs]class KeySpec(TypedDict, total=False):
description: str
default: Any
not_empty: bool
Spec = Dict[str, Dict[str, KeySpec]]
"""A dictionary like this example:
.. code:: python
spec = {
'section_1': {
'key_1': {
'description': 'Lorem ipsum',
'default': 123,
'not_empty': True,
}
}
}
"""
[docs]class ArgparseReader(ReaderBase):
"""This class tries to read configuration values from a `argparse`
namespace object. This works fine if your section is one word long
(`--section-key` = `args.section_key` = `section` + `key`) and not more
than one word long (`--my-section-key` = `args.my_section_key` = `my` +
`section_key`). By multi word section you have to specify a mapping
(`{'my_section.key': 'my_section_key'}`). Without a mapping all sections
and keys are convert into lowercase (`Section` = `section`).
:param args: The parsed `argparse` object.
:param mapping: A dictionary like this one: `{'section.key': 'dest'}`.
`dest` is the property name of the `args` object.
"""
_mapping: Mapping
def __init__(self, args: argparse.Namespace, mapping: Mapping = {}):
self._args = args
self._mapping = mapping
[docs] def get(self, section: str, key: str) -> typing.Any:
"""
Get a configuration value stored under a section and a key.
:param section: Name of the section.
:param key: Name of the key.
:raises ConfigValueError: Configuration value couldn’t be found.
:return: The configuration value stored under a section and a key.
"""
mapping_key = '{}.{}'.format(section, key)
if mapping_key in self._mapping:
argparse_dest = self._mapping[mapping_key]
else:
argparse_dest = '{}_{}'.format(section, key).lower()
if hasattr(self._args, argparse_dest):
value = getattr(self._args, argparse_dest)
if value is not None:
return value
self._exception('Configuration value could not be found by '
'Argparse (section “{}” key “{}”).'
.format(section, key))
[docs]class DictionaryReader(ReaderBase):
"""Useful for default values.
:param dictionary: A nested dictionary.
"""
def __init__(self, dictionary: Dict[str, Any]):
self._dictionary = dictionary
[docs] def get(self, section: str, key: str) -> typing.Any:
"""
Get a configuration value stored under a section and a key.
:param section: Name of the section.
:param key: Name of the key.
:raises ConfigValueError: Configuration value couldn’t be found.
:return: The configuration value stored under a section and a key.
"""
try:
return self._dictionary[section][key]
except KeyError:
self._exception(
'In the dictionary is no value at dict[{}][{}]'
.format(section, key)
)
[docs]class EnvironReader(ReaderBase):
"""Read configuration values from environment variables. The name
of the environment variables have to be in the form `prefix__section__key`.
Note the two following underscores.
:param prefix: A enviroment prefix"""
def __init__(self, prefix: Optional[str] = None):
self._prefix = prefix
[docs] def get(self, section: str, key: str) -> typing.Any:
"""
Get a configuration value stored under a section and a key.
:param section: Name of the section.
:param key: Name of the key.
:raises ConfigValueError: Configuration value couldn’t be found.
:return: The configuration value stored under a section and a key.
"""
if self._prefix:
key = '{}__{}__{}'.format(self._prefix, section, key)
else:
key = '{}__{}'.format(section, key)
if key in os.environ:
return os.environ[key]
self._exception('Environment variable not found: {}'.format(key))
[docs]class IniReader(ReaderBase):
"""Read configuration files from text files in the INI format.
:param path: The path of the INI file.
"""
def __init__(self, path: str):
self._config = configparser.ConfigParser()
if not path or not os.path.exists(path):
raise IniReaderError(
'Ini configuration path “{}” couldn’t be opened.'
.format(path)
)
self._config.read_file(open(path))
[docs] def get(self, section: str, key: str) -> typing.Any:
"""
Get a configuration value stored under a section and a key.
:param section: Name of the section.
:param key: Name of the key.
:raises ConfigValueError: Configuration value couldn’t be found.
:return: The configuration value stored under a section and a key.
"""
try:
return self._config[section][key]
except KeyError:
self._exception('Configuration value could not be found '
'(section “{}” key “{}”).'.format(section, key))
[docs]class SpecReader(ReaderBase):
"""Read the default values from the `spec` (specification) dictionary.
:param spec: The `spec` (specification) dictionary.
"""
_spec: Spec
def __init__(self, spec: Spec):
self._spec = spec
[docs] def get(self, section: str, key: str) -> typing.Any:
"""
Get a configuration value stored under a section and a key.
:param section: Name of the section.
:param key: Name of the key.
:raises ConfigValueError: Configuration value couldn’t be found.
:return: The configuration value stored under a section and a key.
"""
try:
return self._spec[section][key]['default']
except KeyError:
self._exception('Configuration value could not be found '
'(section “{}” key “{}”).'.format(section, key))
# Common code #################################################################
[docs]class ReaderSelector(ReaderBase):
"""Select for each get request which reader to use."""
def __init__(self, *readers: ReaderBase):
self.readers = readers
"""A list of readers."""
@staticmethod
def _validate_key(key: str):
return validate_key(key)
[docs] def get(self, section: str, key: str):
"""
Get a configuration value stored under a section and a key.
:param section: Name of the section.
:param key: Name of the key.
"""
self._validate_key(section)
self._validate_key(key)
for reader in self.readers:
try:
return reader.get(section, key)
except ConfigValueError:
pass
raise ValueError('Configuration value could not be found '
'(section “{}” key “{}”).'.format(section, key))
[docs]def auto_type(value: Any) -> Any:
"""https://stackoverflow.com/a/7019325"""
try:
return ast.literal_eval(value)
except ValueError:
return value
# ERROR: test_method_send_email_with_config_reader
# (test_command_watcher.TestClassWatch)
# AttributeError: 'SyntaxError' object has no attribute 'filename'
except SyntaxError:
return value
[docs]class DictionaryInterfaceKey:
def __init__(self, reader: ReaderBase, section: str):
self._reader = reader
self._section = section
def __getitem__(self, name: str):
return auto_type(self._reader.get(self._section, name))
[docs]class DictionaryInterface:
def __init__(self, reader: ReaderBase):
self._reader = reader
def __getitem__(self, name: str):
return DictionaryInterfaceKey(self._reader, section=name)
[docs]class ClassInterfaceKey:
def __init__(self, reader: ReaderBase, section: str):
self._reader = reader
self._section = section
def __getattr__(self, name: str):
return auto_type(self._reader.get(self._section, name))
[docs]class ClassInterface:
def __init__(self, reader: ReaderBase):
self._reader = reader
def __getattr__(self, name: str):
return ClassInterfaceKey(self._reader, section=name)
[docs]def load_readers_by_keyword(**kwargs: Any) -> List[ReaderBase]:
"""Available readers: `argparse`, `dictionary`, `environ`, `ini`.
The arguments of this class have to be specified as keyword arguments.
Each keyword stands for a configuration reader class.
The order of the keywords is important. The first keyword, more
specifically the first reader class, overwrites the next ones.
:param tuple argparse: A tuple `(args, mapping)`.
`args`: The parsed `argparse` object (Namespace).
`mapping`: A dictionary like this one: `{'section.key': 'dest'}`. `dest`
are the propertiy name of the `args` object.
or only the `argparse` object (Namespace).
:param dict dictonary: A two dimensional nested dictionary
`{'section': {'key': 'value'}}`
:param str environ: The prefix of the environment variables.
:param str ini: The path of the INI file.
"""
readers: List[ReaderBase] = []
for keyword, value in kwargs.items():
if keyword == 'argparse':
if isinstance(value, tuple) or isinstance(value, list):
readers.append(ArgparseReader(args=value[0], mapping=value[1]))
elif value.__class__.__name__ == 'Namespace':
readers.append(ArgparseReader(args=value))
elif keyword == 'dictionary':
readers.append(DictionaryReader(dictionary=value))
elif keyword == 'environ':
readers.append(EnvironReader(prefix=value))
elif keyword == 'ini':
readers.append(IniReader(path=value))
elif keyword == 'spec':
readers.append(SpecReader(spec=value))
return readers
[docs]class ConfigReader:
"""Available readers: `argparse`, `dictionary`, `environ`, `ini`.
The arguments of this class have to be specified as keyword arguments.
Each keyword stands for a configuration reader class.
The order of the keywords is important. The first keyword, more
specifically the first reader class, overwrites the next ones.
:param tuple argparse: A tuple `(args, mapping)`.
`args`: The parsed `argparse` object (Namespace).
`mapping`: A dictionary like this one: `{'section.key': 'dest'}`. `dest`
are the propertiy name of the `args` object.
or only the `argparse` object (Namespace).
:param dict dictonary: A two dimensional nested dictionary
`{'section': {'key': 'value'}}`
:param str environ: The prefix of the environment variables.
:param str ini: The path of the INI file.
"""
spec: Spec
reader: ReaderBase
def __init__(self, spec: Spec = {}, **kwargs):
if spec:
readers = load_readers_by_keyword(**kwargs, spec=spec)
else:
readers = load_readers_by_keyword(**kwargs)
self.spec = spec
"""The specification dictionary. For more informations look at the
class arguments of this class."""
self.reader = ReaderSelector(*readers)
""":py:class:`ReaderSelector`"""
[docs] def get_class_interface(self) -> ClassInterface:
return ClassInterface(self.reader)
[docs] def get_dictionary_interface(self) -> DictionaryInterface:
return DictionaryInterface(self.reader)
[docs] def check_section(self, section: str, not_empty: bool = False) -> bool:
"""Check all keys of a section.
:raises ValueError: If the value is not configured and can not be
read by the readers.
:raises ValueError: If `not_empty` is true and value is empty.
:raises KeyError: By an unspecify section
"""
for key, value_spec in self.spec[section].items():
value = self.reader.get(section, key)
if 'not_empty' in value_spec and \
value_spec['not_empty'] and not value:
raise ValueError('Spec check: section ”{}” key “{}” is empty.'
.format(section, key))
return True
[docs] def spec_to_argparse(self, parser: argparse.ArgumentParser) -> None:
for section, _ in self.spec.items():
group = parser.add_argument_group(
title=section,
description='Generated by the config_reader.'
)
for key, value in self.spec[section].items():
argument = '--{}-{}'.format(section, key).replace('_', '-')
kwargs = {}
if 'description' in value:
kwargs['help'] = value['description']
if 'default' in value:
kwargs['default'] = value['default']
group.add_argument(argument, **kwargs)