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.
276 lines
11 KiB
276 lines
11 KiB
"""
|
|
A Python library for specifying config requirements. Enables configuration to be validated before
|
|
being loaded and used.
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
import re
|
|
import itertools
|
|
|
|
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):
|
|
"""
|
|
The abstract base class for all ConfigSpecifications. `default` values are used to ensure
|
|
that a config structure exists even if optional fields are not supplied.
|
|
"""
|
|
|
|
def __init__(self, helptext=""):
|
|
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, helptext=""):
|
|
super().__init__(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, minval=None, maxval=None, helptext=""):
|
|
super().__init__(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 FloatSpec(_ValueSpecification):
|
|
def __init__(self, minval=None, maxval=None, helptext=""):
|
|
super().__init__(helptext)
|
|
self.minval = minval
|
|
self.maxval = maxval
|
|
|
|
def validate(self, value):
|
|
if not isinstance(value, float):
|
|
raise InvalidConfigError("Config value must be a float")
|
|
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, regex=None, helptext=""):
|
|
"""
|
|
Specify a string to be provided. If `regex` is not None, validation requires that the
|
|
config value directly matches the supplied regex string (calls `re.fullmatch`)
|
|
"""
|
|
super().__init__(helptext)
|
|
self.regex = regex
|
|
|
|
def validate(self, value):
|
|
if not isinstance(value, str):
|
|
raise InvalidConfigError(F"Config value must be a string and is {value}")
|
|
if self.regex and (re.fullmatch(self.regex, value) is None):
|
|
raise InvalidConfigError(F"Config string '{value}' must match regex"
|
|
F" pattern '{self.regex}'")
|
|
|
|
|
|
@preservable
|
|
class DictSpec(_ValueSpecification):
|
|
def __init__(self, helptext=""):
|
|
super().__init__(helptext)
|
|
self.spec_dict = {}
|
|
self.optional_spec_dict = {}
|
|
self.default_values = {}
|
|
|
|
def add_spec(self, name, value_spec, optional=False, default=None):
|
|
"""
|
|
Adds a specification to this dict with the given `name`. To validate, corresponding fields
|
|
must be present with matching keys. Only supports string names/keys.
|
|
|
|
Specifications marked as `optional` will be filled with their default value if not present
|
|
during validation (note, this _will_ modify the data validated to fill in defaults), and
|
|
will also pass validation if the value is None. Providing `default` as anything other than
|
|
None will implicitly set `optional` to True.
|
|
|
|
The default value must be None, or a value that will pass validation with the spec.
|
|
|
|
To allow concise access to the value_spec passed in, returns `value_spec`. This beahviour
|
|
has an exception for ListSpecs, for which it will return the value_spec passed to the
|
|
ListSpec itself (also to allow concise access to further define the spec).
|
|
|
|
Note that nested optional dicts _are_ possible by passing `default` as an empty dict `{}`,
|
|
and passing `value_spec` a DictSpec where every field is itself optional. If this entire
|
|
field is missing during validation it will then attempt to validate an empty dict,
|
|
populating it with all of the nested optional fields.
|
|
"""
|
|
if not isinstance(value_spec, _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")
|
|
if (name in self.spec_dict) or (name in self.optional_spec_dict):
|
|
raise TypeError(F"Name '{name}' already exists in this DictSpec")
|
|
|
|
if default is not None:
|
|
optional = True
|
|
|
|
if not optional:
|
|
self.spec_dict[name] = value_spec
|
|
else:
|
|
self.optional_spec_dict[name] = value_spec
|
|
self.default_values[name] = default
|
|
|
|
if isinstance(value_spec, ListSpec):
|
|
return value_spec.spec
|
|
|
|
return value_spec
|
|
|
|
def add_specs(self, spec_dict, optional=False):
|
|
"""
|
|
Convenience method to pass a series of specifications to 'add_spec()'.
|
|
|
|
Args:
|
|
spec_dict: A dict of specs, where each key is the name of the spec stored as the
|
|
corresponding value. If `optional` is true, the value may be a tuple with the
|
|
first item being the spec and the second being the default value.
|
|
optional: Same behaviour as `add_spec()`. Applies to all specs passed in, and will use
|
|
a default value of None.
|
|
"""
|
|
for name, spec in spec_dict.items():
|
|
if optional and isinstance(spec, tuple):
|
|
self.add_spec(name, spec[0], optional=True, default=spec[1])
|
|
else:
|
|
self.add_spec(name, spec, optional)
|
|
|
|
def validate(self, value_dict):
|
|
"""
|
|
Checks the supplied values 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. Optional values (after defaults are
|
|
inserted) must either be None or pass validation as usual.
|
|
"""
|
|
for missing_key in (self.spec_dict.keys() - value_dict.keys()):
|
|
raise InvalidConfigError(F"Dict must contain key '{missing_key}'")
|
|
|
|
for extra_key in ((value_dict.keys() - self.spec_dict.keys()) -
|
|
self.optional_spec_dict.keys()):
|
|
raise InvalidConfigError(F"Dict contains unknown key '{extra_key}'")
|
|
|
|
for default_key in (self.optional_spec_dict.keys() - value_dict.keys()):
|
|
value_dict[default_key] = self.default_values[default_key]
|
|
|
|
for key, spec in self.spec_dict.items():
|
|
try:
|
|
spec.validate(value_dict[key])
|
|
except InvalidConfigError as e:
|
|
e.args = ("Key: " + key,) + e.args
|
|
raise
|
|
|
|
for key, spec in self.optional_spec_dict.items():
|
|
if value_dict[key] is None:
|
|
continue
|
|
try:
|
|
spec.validate(value_dict[key])
|
|
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 = {}
|
|
if include_optional:
|
|
spec_items = itertools.chain(self.spec_dict.items(), self.optional_spec_dict.items())
|
|
else:
|
|
spec_items = self.spec_dict.items()
|
|
|
|
for key, confspec in spec_items:
|
|
if hasattr(confspec, "get_template"):
|
|
template[key] = confspec.get_template(include_optional)
|
|
else:
|
|
template[key] = self.default_values.get(key, None)
|
|
return template
|
|
|
|
|
|
@preservable
|
|
class ListSpec(_ValueSpecification):
|
|
"""
|
|
Specify a list of values
|
|
"""
|
|
|
|
def __init__(self, value_spec, min_values=None, max_values=None, helptext=""):
|
|
"""
|
|
Make a new ListSpec. Requires a child spec to be applied to all list values.
|
|
Min and max limits can be supplied to ensure a certain range of list sizes.
|
|
Returns the value_spec passed in, to allow easy additions to it (if it's a DictSpec)
|
|
"""
|
|
super().__init__(helptext)
|
|
self.spec = value_spec
|
|
self.min_values = min_values
|
|
self.max_values = max_values
|
|
|
|
def validate(self, value_list):
|
|
if not isinstance(value_list, list):
|
|
raise InvalidConfigError("Config item must be a list")
|
|
if self.min_values and (len(value_list) < self.min_values):
|
|
raise InvalidConfigError(F"Config list requires {self.min_values} values, and only"
|
|
F" has {len(value_list)}")
|
|
if self.max_values and (len(value_list) > self.max_values):
|
|
raise InvalidConfigError(F"Config list can have no more than {self.max_values} values,"
|
|
F" and has {len(value_list)}")
|
|
|
|
for index, value in enumerate(value_list):
|
|
try:
|
|
self.spec.validate(value)
|
|
except InvalidConfigError as e:
|
|
e.args = ("List index: " + str(index),) + e.args
|
|
raise
|
|
|
|
def get_template(self, include_optional=False):
|
|
if hasattr(self.spec, "get_template"):
|
|
return [self.spec.get_template(include_optional)]
|
|
else:
|
|
return [None]
|
|
|
|
|
|
@preservable
|
|
class ConfigSpecification(DictSpec):
|
|
pass
|