You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

223 lines
7.2 KiB

"""
Configuration management module. Enables configuration to be validated against
requirement specifications before being loaded and used.
Compatible with both raw config data structures and TOML files, config data must
start with a root dict containing named "config bundles". These are intended to
refer to different modular parts of the application needing configuration, and the
config data structure must contain at least one.
Each config bundle itself needs to have a dict at the root, and so in practice a minimal
TOML config file would look like::
[myapp]
config_thingy_a = "foooooo!"
important_number = 8237
This would resolve to a config bundle named "myapp" that results in the dict::
{"config_thingy_a": "foooooo!", "important_number": 8237}
Root items that are not dicts are not supported, for instance both the following
TOML files would fail::
[[myapp]]
important_number = 8237
[[myapp]]
another_important_number = 2963
(root object in bundle is a list)
::
root_thingy = 46
(root object in config is a single value)
"""
from abc import ABC, abstractmethod
from preserve import preservable
class InvalidConfigError(Exception):
pass
# The Table and Array terms from the TOML convention essentially
# map directly to Dictionaries (Tables), and Lists (Arrays)
class _ValueSpecification(ABC):
def __init__(self, default=None, optional=False, helptext=""):
self.default = default
self.optional = optional
self.helptext = helptext
@abstractmethod
def validate(self, value):
"""
Checks the supplied value to confirm that it complies with this specification.
Raises InvalidConfigError on failure.
"""
pass
@preservable
class BoolSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""):
super().__init__(default, optional, helptext)
def validate(self, value):
if not isinstance(value, bool):
raise InvalidConfigError("Config value must be a boolean")
@preservable
class IntSpec(_ValueSpecification):
def __init__(self, default=None, minval=None, maxval=None,
optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.minval = minval
self.maxval = maxval
def validate(self, value):
if not isinstance(value, int):
raise InvalidConfigError("Config value must be an integer")
if self.minval is not None and value < self.minval:
raise InvalidConfigError("Config value must be >= " +
str(self.minval))
if self.maxval is not None and value > self.maxval:
raise InvalidConfigError("Config value must be <= " +
str(self.maxval))
@preservable
class StringSpec(_ValueSpecification):
def __init__(self, default="", minlength=None, maxlength=None,
optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.minlength = minlength
self.maxlength = maxlength
def validate(self, value):
if not isinstance(value, str):
raise InvalidConfigError(F"Config value must be a string and is {value}")
if self.minlength is not None and len(value) < self.minlength:
raise InvalidConfigError("Config string length must be >= " +
str(self.minlength))
if self.maxlength is not None and len(value) > self.maxlength:
raise InvalidConfigError("Config string length must be <= " +
str(self.maxlength))
@preservable
class DictSpec(_ValueSpecification):
def __init__(self, default=None, optional=False, helptext=""):
super().__init__(default, optional, helptext)
self.spec_dict = {}
def add_spec(self, name, newspec):
if not isinstance(newspec, _ValueSpecification):
raise TypeError("Config specification must be an instance of a "
"_ValueSpecification subclass")
if not isinstance(name, str):
raise TypeError("Config specification name must be a string")
self.spec_dict[name] = newspec
return newspec
def validate(self, value_dict):
"""
Checks the supplied value to confirm that it complies with this specification.
Raises InvalidConfigError on failure.
This *can* modify the supplied value dict, inserting defaults for any child
config specifications that are marked as optional.
"""
spec_set = set(self.spec_dict.keys())
value_set = set(value_dict.keys())
for missing_key in spec_set - value_set:
if not self.spec_dict[missing_key].optional:
raise InvalidConfigError("Dict must contain key: " +
missing_key)
else:
value_dict[missing_key] = self.spec_dict[missing_key].default
for extra_key in value_set - spec_set:
raise InvalidConfigError("Dict contains unknown key: " +
extra_key)
for key, value in value_dict.items():
try:
self.spec_dict[key].validate(value)
except InvalidConfigError as e:
e.args = ("Key: " + key,) + e.args
raise
def get_template(self, include_optional=False):
"""
Return a config dict with the minimum structure required for this specification.
Default values will be included, though not all required fields will necessarily have
defaults that successfully validate.
Args:
include_optional: If set true, will include *all* config fields, not just the
required ones
Returns:
Dict containing the structure that should be passed back in (with values) to comply
with this config specification.
"""
template = {}
for key, confspec in self.spec_dict.items():
if confspec.optional and (not include_optional):
continue
if hasattr(confspec, "get_template"):
template[key] = confspec.get_template(include_optional)
else:
template[key] = confspec.default
return template
class _ListSpecMixin():
def validate(self, value_list):
if not isinstance(value_list, list):
raise InvalidConfigError("Config item must be a list")
for index, value in enumerate(value_list):
try:
super().validate(value)
except InvalidConfigError as e:
e.args = ("List index: " + str(index),) + e.args
raise
def get_template(self, include_optional=False):
if hasattr(super(), "get_template"):
return [super().get_template(include_optional)]
else:
return [self.default]
@preservable
class BoolListSpec(_ListSpecMixin, BoolSpec):
pass
@preservable
class IntListSpec(_ListSpecMixin, IntSpec):
pass
@preservable
class StringListSpec(_ListSpecMixin, StringSpec):
pass
@preservable
class DictListSpec(_ListSpecMixin, DictSpec):
pass
@preservable
class ConfigSpecification(DictSpec):
pass