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.

191 lines
6.3 KiB

"""
A Python library for specifying config requirements. Enables configuration to be validated before
being loaded and used.
"""
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