"""User config management."""
import sys
import collections
from pathlib import Path
import argparse
from pkg_resources import iter_entry_points
from appdirs import AppDirs
[docs]class MappingMixin(object):
"""Methods for emulating a mapping type."""
def __getattr__(self, key):
return self._data[key]
def __setattr__(self, key, value):
if key in self.__dict__:
self.__dict__[key] = value
elif self._elements is None or key not in self._elements:
self.__dict__[key] = value
else:
self._elements[key].validate(value)
self._data[key] = value
def __len__(self):
return len(self._elements)
def __getitem__(self, key):
return self._dict['key']
def __setitem__(self, key, value):
if key not in self._elements:
raise AttributeError(
'no field with name {}'.format(key))
self._elements[key].validate(value)
self._data[key] = value
def __iter__(self):
return iter(self._data)
def __reversed__(self):
return reversed(self._data)
def __contains__(self, item):
return item in self._elements
[docs] def keys(self):
"""Return a view of dictionary keys."""
return self._data.keys()
[docs] def values(self):
"""Return a view of dictionary values."""
return self._data.values()
[docs] def items(self):
"""Return a view of dictionary key, value pairs."""
return self._data.items()
[docs] def get(self, key, default):
"""Get items without risking a KeyError."""
return self._data.get(key, default)
[docs] def update(self, *args, **kwargs):
"""Update more than one key at a time."""
# TODO: validate
self._data.update(*args, **kwargs)
[docs]class ConfigElement(object):
"""
Base class for configuration elements.
Keyword Arguments
-----------------
doc: str, optional
documentation for this option, defaults to None
default: Any, optional
fallback value, defaults to None
required: bool, optional
MUST a value be present? If no default is provided, this can
result in a MissingData exception. Defaults to True
short_name: str, optional
short name for use with command line arguments, defaults to None
long_name: str, optional
overwrite default name for command line arguments, defaults to None
validate: Callable[Any, None], optional
additional validation function, defaults to None
Raises
------
InvalidArgument:
if default value does not pass validation
Attributes
----------
element_name: str
name of instance, provided by containing class
type_: type
python type of variable that this class represents
Examples
--------
..doctest::
>>> TODO
"""
element_name = None
type_ = str
def __init__(
self,
doc=None,
default=None,
required=True,
short_name=None,
long_name=None,
validate=None):
self.doc = doc
self._default = default
self.required = required
if short_name is None or short_name.startswith('-'):
self._short_name = short_name
else:
self._short_name = '-{}'.format(short_name)
if long_name is None or long_name.startswith('--'):
self._long_name = long_name
else:
self._long_name = '--{}'.format(long_name)
self._validate = validate
if self._default is not None:
self.validate(self._default)
[docs] def has_default(self):
"""Return True if element has a default value."""
return self._default is not None
[docs] def get_default(self):
"""Return default value."""
return self._default
[docs] def construct_parser(self, parser):
"""
Add self to parser.
Parameters
----------
parser: argparse.ArgumentParser
the argument parser to add an option to
Raises
------
None
Returns
-------
None
Examples
--------
..doctest::
>>> TODO
"""
name = []
if self._short_name is not None:
name.append(self._short_name)
if self._long_name is not None:
name.append(self._long_name)
else:
name.append("--{}".format(self.element_name))
parser.add_argument(
*name,
action='store',
#nargs=1,
default=None,
type=self.type_,
choices=None,
required=False,
help=self.doc)
[docs] def validate(self, value):
"""
Validate individual value.
Parameters
----------
value: Any
to be validated
Raises
------
InvalidData:
if validation fails
Returns
-------
None
Examples
--------
..doctest::
>>> TODO
"""
if value is None:
return
if not isinstance(value, self.type_):
raise InvalidData('expected a {}, not {}'.format(
self.type_, value))
if self._validate is not None:
self._validate(value)
[docs] def validate_data(self, data):
"""
Validate data.
Parameters
----------
data: Dict
data structure in which to find and validate our own element
Raises
------
InvalidData:
if validation fails
MissingData:
if `self.required` is `True` and our value in `data`
is `None`
Returns
-------
None
Examples
--------
..doctest::
>>> TODO
"""
if self.required and data[self.element_name] is None:
# none of the configuration locations provided a required
# value, raise an error now
raise MissingData(
'no value was provided for required option {}'.format(
self.element_name))
if data[self.element_name] is not None:
self.validate(data[self.element_name])
[docs]class Section(ConfigElement, MappingMixin):
"""
Named container that contains ConfigElements.
Keyword Arguments
-----------------
doc: str, optional
documentation for this option, defaults to None
default: Any, optional
IGNORED
required: bool, optional
MUST section be present? If no default is provided for any
required content elements, this can result in a
MissingData exception. to find out if an optional section
is complete, see `self.incomplete_count`. Defaults to True
short_name: str, optional
IGNORED
long_name: str, optional
IGNORED
validate: Callable[Any, None], optional
additional validation function, defaults to None
**content: ConfigElement, optional
content of section
Raises
------
AttributeError:
if content element is not a `ConfigElement`
Attributes
----------
incomplete_count: int
Number of content elements which are required, but do not have a
value. Useful for sections which are not marked as required, but
do have required elements.
Examples
--------
..doctest::
>>> TODO
"""
incomplete_count = 0
_elements = None
_data = None
def __init__(
self,
doc=None,
default=None,
required=True,
short_name=None,
long_name=None,
validate=None,
**content):
self._elements = collections.OrderedDict()
self._data = collections.OrderedDict()
ConfigElement.__init__(
self,
doc=doc,
required=required,
short_name=short_name,
long_name=long_name,
validate=validate)
for element in content:
if not isinstance(content[element], ConfigElement):
raise AttributeError(
'{} is not a ConfigElement'.format(element))
content[element].element_name = element
self._elements[element] = content[element]
[docs] def has_default(self):
"""Return True because Section always has a default value."""
return True
[docs] def get_default(self):
"""Fetch and store default Section elements."""
for element in self._elements:
if self._elements[element].has_default():
self._data[element] = self._elements[element].get_default()
else:
self._data[element] = None
return self
[docs] def get_elements(self):
"""Return elements and data."""
return self._elements, self._data
[docs] def construct_parser(self, parser):
for element in self._elements:
self._elements[element].construct_parser(parser)
[docs] def validate(self, value):
pass
[docs] def validate_data(self, _):
for element in self._elements:
try:
self._elements[element].validate_data(self._data)
except MissingData:
if not self.required:
self.incomplete_count += 1
else:
raise
[docs]class StringOption(ConfigElement):
"""Configuration element with string value."""
type_ = str
[docs]class IntegerOption(ConfigElement):
"""Configuration element with integer value."""
type_ = int
[docs]class FloatOption(ConfigElement):
"""Configuration element with float value."""
type_ = float
[docs]class BooleanOption(ConfigElement):
"""Configuration element with boolean value."""
type_ = bool
[docs]class InvalidConfigTree(Exception):
"""Inappropriate configuration tree for file type."""
[docs]class InvalidData(Exception):
"""User supplied invalid data for a configuration element."""
[docs]class MissingData(Exception):
"""An element marked as required is missing a value."""
[docs]class Config(with_metaclass(ConfigMeta, MappingMixin)):
"""
Base class for application configuration.
Raises
------
AttributeError:
if `application` or `author` is not set
InvalidConfigTree:
if configuration tree is inappropriate for `file_type`
InvalidData:
if user supplied invalid data for a configuration element
MissingData:
if an element marked as required has no value
Attributes
----------
file_type: str
file type to use for configuration files
application: str
application name
author: str
application author
version: str, optional
application version (set if your configuration is version
dependent)
Examples
--------
..doctest::
>>> TODO
"""
file_type = "ini"
application = None
author = None
version = None
_data = None
def __init__(self):
if self.application is None:
raise AttributeError(
'application not set, please provide an application name')
if self.author is None:
raise AttributeError(
'author not set, please provide an application author')
self._data = collections.OrderedDict()
# validate _elements
self._validate(self._elements)
# populate _data
for element in self._elements:
if self._elements[element].has_default():
self._data[element] = self._elements[element].get_default()
else:
self._data[element] = None
# read global config
paths = AppDirs(self.application, self.author, self.version)
global_path = Path(paths.site_config_dir).joinpath(
"config.{}".format(self._extension))
if global_path.is_file():
self._read(global_path, self._elements, self._data)
# read user config
user_path = Path(paths.user_config_dir).joinpath(
"config.{}".format(self._extension))
if user_path.is_file():
self._read(user_path, self._elements, self._data)
# construct a commandline parser
parser = argparse.ArgumentParser(
prog=self.application,
description="{}\n\n{}\n{}\n{}".format(
self.__doc__,
"Command line arguments overwrite configuration found in:",
user_path,
global_path))
parser.add_argument(
'--generate-config',
action='store_const',
const=True,
default=False,
required=False,
help="print a complete configuration file with current settings")
for element in self._elements:
self._elements[element].construct_parser(parser)
command_line_arguments = vars(parser.parse_args())
# check if we should print a configuration file
if command_line_arguments['generate_config']:
self._write(self._elements, self._data, self.__doc__)
sys.exit(True)
# put command line argument data into _data
for element in self._elements:
self._elements[element].extract_data_from_parser(
command_line_arguments, self._data)
# validate _data
self._elements[element].validate_data(self._data)
def __getattr__(self, name):
return self._data[name]