"""Definition of a configuration schema. Part of the tagit module. A copy of the license is provided with the project. Author: Matthias Baumgartner, 2022 """ # standard imports from collections import abc import logging import typing # tagit imports from tagit.utils import errors, fst # exports __all__: typing.Sequence[str] = ( 'ConfigSchema', 'declare', 'declare_title', ) ## code ## logger = logging.getLogger(__name__) class IncompatibleTypes(Exception): """Raised if a config key is declared multiple times with incompatible signatures.""" pass class ConfigSchema(abc.Collection, abc.Callable, abc.Hashable): """The config schema registers types, defaults, and documentation for configuration keys. The specification of a config key can be accessed in dict-style (schema[key]) or Settings-style (schema(key)). A global schema is instantiated to be used by tagit modules to declare their config keys. In addition to config keys, the class supports titles for documentation of configuration sections (essentially any part of a config key that has no value assigned to it). """ def __init__(self): self.config = dict() self.titles = dict() ## interfaces def __hash__(self): return hash((type(self), tuple(sorted([(hash(k), hash(v)) for k, v in self.config.items()], key=fst)), tuple(sorted([(hash(k), hash(v)) for k, v in self.titles.items()], key=fst)))) def __call__(self, *key): """Return the definition of a *key*.""" return self.config[key] def __getitem__(self, key): """Return the definition of a *key*.""" return self.config[key] def __contains__(self, key): """Return True if the config key *key* was declared.""" return key in self.config def __iter__(self): """Iterate over all declared config keys.""" return iter(self.config) def __len__(self): """Return the number of declared config keys.""" return len(self.config) def keys(self, titles=False): """Return an iterator over all declared config keys. If *titles* is True, also return the declared title keys. """ if titles: return iter(set(self.config.keys()) | set(self.titles.keys())) else: return self.config.keys() ## titles extras def is_title(self, key): """Return True if the *key* matches a section.""" return key in self.titles and key not in self.config def get_title(self, key): """Return the section title of *key*.""" return self.titles[key] ## declaration interface def declare(self, key, type, default, module=None, title=None, description=None, example=None): """Declare a configuration key. A key cannot be declared multiple times unless it has the same type annotation and default value. :param:`key` Configuration key as tuple :param:`type` Value type definition :param:`default` Default value :param:`module` Declaring module :param:`title` Reader friendly name :param:`description` Verbose description of its effect :param:`example` Usage example """ if len(key) == 0: raise errors.ProgrammingError('the config key must contain at least one item.') # FIXME: can't have a rule for a subkey # e.g. ('session', ): String() and ('session', 'verbose'): Int() key = tuple(key) if key in self.config: # declaration exists, check compatibility if self.config[key].type == type and \ self.config[key].default == default: # types are compatible, set/overwrite values self.config[key].modules = module self.config[key].title = title self.config[key].description = description self.config[key].example = example logger.warning(f'config schema: potentially overwriting key {key}') else: raise IncompatibleTypes(f'declaration of {key} violates a previous declaration') elif type.check(default): self.config[key] = ConfigKey(key, type, default, module, title, description, example) else: raise errors.ProgrammingError('default value violates value type specification') def declare_title(self, key, module, title, description=None): """Declare a config section title. Section titles are only used for documentation purposes. :param:`key` Configuration key as tuple :param:`module` Declaring module :param:`title` Reader friendly name :param:`description` Verbose description of its effect """ if len(key) == 0: raise errors.ProgrammingError('the config key must contain at least one item.') key = tuple(key) if key in self.titles: self.titles[key].title = title self.titles[key].modules = module self.titles[key].description = description logger.warn(f'config schema: potentially overwriting title {key}') else: self.titles[key] = ConfigTitle(key, title, module, description) class ConfigTitle(abc.Hashable): """Title and description of a config key. Used for documentation.""" def __init__(self, key, title=None, module=None, description=None): self._key = key self._title = title self._description = description self._modules = {module} if module is not None else set() def __repr__(self): return f'ConfigTitle({self.key}, {self.title})' def __eq__(self, other): return isinstance(other, type(self)) and self._key == other._key def __hash__(self): return hash((type(self), self._key)) @property def branch(self): """Return the branch.""" return self._key[:-1] @property def leaf(self): """Return the leaf.""" return self._key[-1] @property def key(self): """Return the key.""" return self._key @property def title(self): """Return the key's title.""" return self._title if self._title is not None else self.leaf @title.setter def title(self, title): """Overwrite the key's title.""" if title is not None and title != '': self._title = title @property def description(self): """Return the key's description.""" return self._description if self._description is not None else '' @description.setter def description(self, description): """Overwrite the key's description.""" if description is not None and description != '': self._description = description @property def modules(self): """Return the module names that declared the key.""" return self._modules @modules.setter def modules(self, module): """Add another declaring module.""" if module is not None and module != '': self._modules.add(module) class ConfigKey(ConfigTitle): """Define the type and default value of a configuration key.""" def __init__(self, key, type, default, module=None, title=None, description=None, example=None): super(ConfigKey, self).__init__(key, title, module, description) self._type = type self._default = default self._examples = {example} if example is not None else set() def __repr__(self): return f'ConfigKey({self.key}, {self.type}, {self.default})' def __eq__(self, other): return super(ConfigKey, self).__eq__(other) and \ self._type == other._type and \ self._default == other._default def __hash__(self): return hash((super(ConfigKey, self).__hash__(), self._type, self._default)) def check(self, value): """Return True if *value* adheres to the key's type specification.""" return self.type.check(value) def backtrack(self, value): """Return True if *value* matches the key's type, raises a TypeError otherwise.""" self.type.backtrack(value, '.'.join(self.key)) return True @property def default(self): """Return the default value.""" return self._default @property def type(self): """Return the type definition.""" return self._type @property def example(self): """Return an example value.""" return ', '.join(self._examples) if len(self._examples) else self.type.example @example.setter def example(self, example): """Add more examples for the key.""" if example is not None and example != '': self._examples.add(example) ## global instance schema = ConfigSchema() def declare(*args, **kwargs): schema.declare(*args, **kwargs) def declare_title(*args, **kwargs): schema.declare_title(*args, **kwargs) ## EOF ##