""" 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 add_specs(self, spec_list): """ Convenience method to pass a series of specifications to 'add_spec()' Args: spec_list: A list of tuples, where each tuple contains a `name` and `newspec` value, to be passed in the same form as a call to `add_spec` """ for spec in spec_list: self.add_spec(*spec) 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