""" 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