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