diff options
Diffstat (limited to 'tagit/config')
-rw-r--r-- | tagit/config/__init__.py | 25 | ||||
-rw-r--r-- | tagit/config/loader.py | 79 | ||||
-rw-r--r-- | tagit/config/schema.py | 283 | ||||
-rw-r--r-- | tagit/config/settings.py | 479 | ||||
-rw-r--r-- | tagit/config/settings.yaml | 10 | ||||
-rw-r--r-- | tagit/config/types.py | 273 | ||||
-rw-r--r-- | tagit/config/user-defaults.yaml | 107 | ||||
-rw-r--r-- | tagit/config/utils.py | 104 |
8 files changed, 1360 insertions, 0 deletions
diff --git a/tagit/config/__init__.py b/tagit/config/__init__.py new file mode 100644 index 0000000..c9edb15 --- /dev/null +++ b/tagit/config/__init__.py @@ -0,0 +1,25 @@ +"""Configuration system. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# inner-module imports +from . import loader +from . import utils +from .loader import TAGITRC, DEFAULT_USER_CONFIG +from .schema import schema, declare, declare_title +from .settings import Settings, ConfigError +from .types import * + +# exports +__all__: typing.Sequence[str] = ( + 'Settings', + 'TAGITRC', + 'schema', + ) + +## EOF ## diff --git a/tagit/config/loader.py b/tagit/config/loader.py new file mode 100644 index 0000000..47a51fa --- /dev/null +++ b/tagit/config/loader.py @@ -0,0 +1,79 @@ +"""High-level config loading. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import logging +import os +import shutil +import typing + +# inner-module imports +from .settings import Settings + +# constants + +TAGITRC = '.tagitrc' + +DEFAULT_USER_CONFIG = os.path.join(os.path.dirname(__file__), 'user-defaults.yaml') + +SETTINGS_PATH = [ + # user home + os.path.expanduser(os.path.join('~', TAGITRC)), + # installation directory + '/usr/share/tagit/settings', + '/usr/share/tagit/keybindings', + # module defaults + os.path.join(os.path.dirname(__file__), 'settings.yaml'), + ] + +# exports +__all__: typing.Sequence[str] = ( + 'load_settings', + ) + + +## code ## + +logger = logging.getLogger(__name__) + +def load_settings(path=None, verbose=0): + """Load application settings. + The settings are loaded from the specified *path* and from all default + search paths (see *SETTINGS_PATH*). More specific locations overwrite + less specific ones. Every config key comes with a default value that + applies if it is not specified in the config files. + """ + verbose = max(0, verbose) + + # build searchpaths + searchpaths = [] + searchpaths += [path] if path is not None else [] + searchpaths += SETTINGS_PATH + + # create default user config on first start + user_config = os.path.expanduser(os.path.join('~', TAGITRC)) + if os.path.exists(DEFAULT_USER_CONFIG) and not os.path.exists(user_config): + shutil.copy(DEFAULT_USER_CONFIG, user_config) + + # scan searchpaths + cfg = Settings() + for path in searchpaths[::-1]: + if verbose > 0 or cfg('session', 'verbose') > 0: + print(f'Loading settings from {path}') + + if path is not None and os.path.exists(path): + try: + cfg.update(Settings.Open(path, clear_defaults=False)) + + except TypeError as e: # schema violation + logger.critical(f'Encountered a config error while loading {path}: {e}') + raise e + + # update verbosity from argument + cfg.set(('session', 'verbose'), max(cfg('session', 'verbose'), verbose)) + return cfg + +## EOF ## diff --git a/tagit/config/schema.py b/tagit/config/schema.py new file mode 100644 index 0000000..7f1c17a --- /dev/null +++ b/tagit/config/schema.py @@ -0,0 +1,283 @@ +"""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 ## diff --git a/tagit/config/settings.py b/tagit/config/settings.py new file mode 100644 index 0000000..190268c --- /dev/null +++ b/tagit/config/settings.py @@ -0,0 +1,479 @@ +"""Configuration storage. + +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 +from copy import deepcopy +import io +import json +import os +import typing + +# external imports +import yaml # FIXME: mb/port/convenicence + +# tagit imports +from tagit.utils import errors, fst, is_list + +# inner-module imports +from . import types +from .schema import schema as global_schema +from .utils import key_starts_with, superkey_of, subkey_of + +# constants +INDENT = 4 + +# exports +__all__: typing.Sequence[str] = ( + 'Settings', + ) + + +## code ## + +class ConfigError(TypeError): pass + +class Settings(abc.MutableMapping, abc.Hashable, abc.Callable): + """Access and modify config keys in a dict-like manner. + + It's assumed that the schema might not be available for all config + elements. That's because it might be declared in code that is + not yet or will never be loaded. In such cases, any value is accepted. + If the schema is known, however, it is enforced. + """ + + ## construction + + def __init__(self, schema=None, prefix=None, data=None): + self.prefix = tuple(prefix) if prefix is not None else tuple() + self.schema = schema if schema is not None else global_schema + self.config = data if data is not None else dict() + + @classmethod + def Open(cls, source, schema=None, on_error='raise', clear_defaults=True): + schema = schema if schema is not None else global_schema + config_path = '' + # load source + if isinstance(source, dict): # dictionary + config = source + elif isinstance(source, str): # path or serialized + if os.path.exists(source): + config_path = os.path.realpath(source) + with open(source, 'r') as ifile: + #config = json.load(ifile) + config = yaml.safe_load(ifile) # FIXME: mb/port/convenicence + else: + #config = json.loads(source) + config = yaml.safe_load(source) # FIXME: mb/port/convenicence + elif isinstance(source, io.TextIOBase): # opened file + #config = json.load(source) + config = yaml.safe_load(source) # FIXME: mb/port/convenicence + else: + raise TypeError('expected dict, path, or file-like') + + # flatten and verify + data = None + if len(config) > 0: + data = cls.flatten_tree(config, schema, on_error=on_error) + if clear_defaults: + # filter defaults + data = {key: value + for key, value in data.items() + if key not in schema or value != schema[key].default} + + data['session', 'paths', 'config'] = config_path + return cls(schema=schema, data=data) + + def update(self, other): + for key, value in other.config.items(): + self.set(key, value) + return self + + def rebase(self, schema=None, on_error='raise', clear_defaults=True): + """Re-align the config with the current schema. + Should be done if the schema changes *after* the Settings was initialized. + Can also be used to enforce a new schema on the current config. + + Be aware that calling rebase will disconnect Settings instances from + each other. For example, this affects non-leaf key retrieval via + get such as cfg('session') + """ + schema = self.schema if schema is None else schema + # unroll + tree = dict() + for key, value in self.config.items(): + path, leaf = list(key[:-1]), key[-1] + # navigate through the path + branch = tree + while len(path): + curr = path.pop(0) + if curr not in branch: + branch[curr] = dict() + branch = branch[curr] + + branch[leaf] = value + + # flatten the unrolled config + flat = self.flatten_tree(tree, schema, on_error=on_error) + # remove defaults + if clear_defaults: + flat = {key: value + for key, value in flat.items() + if key not in schema or value != schema[key].default} + # set new schema and config + self.config = flat + self.schema = schema + + ## comparison + + def __eq__(self, other): + return isinstance(other, Settings) and \ + self.schema == other.schema and \ + self.config == other.config and \ + self.prefix == other.prefix + + def __hash__(self): + return hash((type(self), + self.prefix, + hash(self.schema), + hash(tuple(sorted(self.config.items(), key=fst))))) + + def __str__(self): + return str({k: v for k, v in self.config.items() if key_starts_with(k, self.prefix)}) + + def __repr__(self): + prefix = ','.join(self.prefix) + size_self = len([key for key in self.config if key_starts_with(key, self.prefix)]) + size_all = len(self) + return f'Settings(prefix=({prefix}), keys={size_self}, len={size_all})' + + ## conversion + + def clone(self): + return Settings(schema=self.schema, prefix=self.prefix, data=deepcopy(self.config)) + + @staticmethod + def flatten_tree(hdict, schema, on_error='raise', prefix=tuple()): + """Flattens a hierarchical dictionary by using schema information. + Returns a flat list of config keys and their values. + + If an invalid type was found and on_error is 'raise, a TypeError is raised. + Otherwise the invalid key is ignored. + """ + if len(hdict) == 0: + # not in schema, or passed the check + return {prefix: dict()} + + flat = dict() + for sub in hdict: + try: + key = prefix + (sub, ) + # check schema first, to preserve dict types + if key in schema and schema[key].backtrack(hdict[sub]): + # accept the value (also defaults!) + flat[key] = hdict[sub] + + elif isinstance(hdict[sub], dict): + flat.update(Settings.flatten_tree(hdict[sub], schema, on_error, key)) + + elif any(key_starts_with(k, key) for k in schema): + subkeys = [k[len(key):] for k in schema if key_starts_with(k, key)] + subkeys = ','.join('.'.join(k) for k in subkeys) + raise ConfigError( + f'found value {hdict[sub]} in {key}, expected keys ({subkeys})') + + else: + # terminal but not in schema; accept + flat[key] = hdict[sub] + + except TypeError as e: + if on_error == 'raise': + raise e + + return flat + + def to_tree(self, defaults=False): + """Return a nested dictionary with all config values. + If *defaults*, the schema defaults are included. + """ + tree = dict() + source = set(self.config.keys()) + if defaults: + source |= set(self.schema.keys()) + + for key in source: + if not key_starts_with(key, self.prefix): + continue + value = self.get(*key[len(self.prefix):]) + path, leaf = list(key[:-1]), key[-1] + path = path[len(self.prefix):] + # navigate through the path + branch = tree + while len(path): + curr = path.pop(0) + if curr not in branch: + branch[curr] = dict() + branch = branch[curr] + + branch[leaf] = value + + return tree + + def file_connected(self): + """Return True if the config is backed by a file.""" + return self('session', 'paths', 'config') is not None and \ + self('session', 'paths', 'config') != '' + + def save(self, uri=None): + """Save changes to a file at *uri*.""" + # pick defaults + uri = uri if uri is not None else self('session', 'paths', 'config') + if uri is None or uri == '': + raise ValueError('config saving requires a valid uri') + + # convert to tree + config = self.to_tree(defaults=False) + + # save to file + if isinstance(uri, io.TextIOBase): + json.dump(config, uri, indent=INDENT) + else: + with open(uri, 'w') as ofile: + json.dump(config, ofile, indent=INDENT) + + def diff(self, other): + """Return a config that includes only the keys which differ from *other*.""" + # keys in self that differ from other + config = {key: value + for key, value in self.config.items() + if key not in other.config or value != other.config[key] + } + # keys in other that differ from default + config.update({key: self.schema[key].default + for key, value in other.config.items() + if key not in self.config and \ + key in self.schema and \ + value != self.schema[key].default + }) + + return Settings(schema=self.schema, prefix=self.prefix, data=deepcopy(config)) + + + ## getting + + def __getitem__(self, key): + """Alias for *get*.""" + if is_list(key): + return self.get(*key) + else: + return self.get(key) + + def __call__(self, *key, default=None): + """Alias for *get*.""" + return self.get(*key, default=default) + + def get(self, *key, default=None): + key = self.prefix + key + + # known leaf + if key in self.config: + value = self.config[key] + if key in self.schema: + if self.schema[key].check(value): + return value + elif default is not None and self.schema[key].check(default): + return default + else: + return self.schema[key].default + else: + return value + + # unknown leaf + if key in self.schema: + if default is not None and self.schema[key].check(default): + return default + else: + return self.schema[key].default + + # branch + if any(key_starts_with(sub, key) for sub in self.config): + return Settings(schema=self.schema, prefix=key, data=self.config) + elif any(key_starts_with(sub, key) for sub in self.schema): + return Settings(schema=self.schema, prefix=key, data=self.config) + + if default is not None: + return default + + raise KeyError(key) + + ## checking + + def __contains__(self, key): + """Alias for *has*.""" + return self.has(*key) + + def has(self, *key): + key = self.prefix + key + + if key in self.config: + # key is a known leaf + return True + elif key in self.schema: + # key is an unknown leaf + return True + else: + # key might be a branch + for sub in self.config: + if key_starts_with(sub, key): + return True + + for sub in self.schema: + if key_starts_with(sub, key): + return True + + return False + + ## setting + + def __setitem__(self, key, value): + """Alias for *set*.""" + if is_list(key): + self.set(key, value) + else: + self.set((key, ), value) + + def set(self, key, value): + key = self.prefix + key + + if key in self.schema and self.schema[key].backtrack(value): + if self.schema[key].default != value: + self.config[key] = value + elif key in self.config: # value is default + # reset value to default, remove from config + del self.config[key] + # else: value was default but not present, ignore + + elif key in self.config: + self.config[key] = value + + elif isinstance(value, dict) and len(value) > 0: + # flatten value and set its items individually + subtree = self.flatten_tree(value, self.schema, prefix=key) + for subkey, subval in subtree.items(): + # defaults will be filtered by set + self.set(subkey[len(self.prefix):], subval) + + elif superkey_of(key, self.schema) or subkey_of(key, self.schema): + # schema violation in another branch + conflicts = {'.'.join(sub) + for sub in self.schema + if key_starts_with(sub, key) or key_starts_with(key, sub)} + raise ConfigError(f'{key} conflicts with schema keys {",".join(conflicts)}') + + elif superkey_of(key, self.config): + # it's allowed to overwrite dict-like config values + # Example: + # having defined session.paths.preview.files = 'somewhere' + # it's allowed to set session.paths.preview = {} + # It's admissible iff: + # * the value is an empty dict + # * no subkey is in the schema (already checked in the case above) + if value == dict(): + self.unset(*key) + self.config[key] = value + else: + conflicts = {'.'.join(sub) + for sub in self.schema + if key_starts_with(sub, key) or key_starts_with(key, sub)} + raise ConfigError(f'{key} conflicts with config keys {",".join(conflicts)}') + + elif subkey_of(key, self.config): + # it's allowed to overwrite dict-like config values + # Example: + # having defined session.paths.preview = {} + # it's allowed to set session.paths.preview.files = 'somewhere' + # It's admissible iff: + # * the superkey is an empty dict + # * no subkey of the superkey is in the schema + sups = [sup for sup in self.config if key_starts_with(key, sup)] + if len(sups) != 1: + # there can only be one super-key + raise errors.ProgrammingError(f'expected one superkey, found {len(sups)}') + + sup = sups[0] + if self.config[sup] == dict() and \ + len({sub for sub in self.schema if key_starts_with(sup, sub)}) == 0: + del self.config[sup] + self.config[key] = value + else: + # already have a superkey in the config that cannot be overwritten + conflicts = '.'.join(sup) + raise ConfigError(f'{key} conflicts with config keys {conflicts}') + + else: + self.config[key] = value + + return self + + ## removal + + def __delitem__(self, key): + """Alias for *unset*.""" + if is_list(key): + self.unset(*key) + else: + self.unset(key) + + def unset(self, *key): + key = self.prefix + key + + if key in self.config: + # key is a leaf + del self.config[key] + else: + # key might be a branch + subs = [sub for sub in self.config if key_starts_with(sub, key)] + for sub in subs: + del self.config[sub] + + return self + + ## iteration + + def __iter__(self): + """Alias for *keys*.""" + return self.keys() + + def keys(self): + for key in set(self.config.keys()) | set(self.schema.keys()): + if key_starts_with(key, self.prefix): + yield key[len(self.prefix):] + + def items(self): + for key in self.keys(): + yield key, self.get(*key) + + ## properties + + def __len__(self): + return len(list(self.keys())) + + +## config ## + +global_schema.declare(('session', 'verbose'), types.Unsigned(), 0, + __name__, 'Verbosity', 'Print additional information in various places of the application.') + +global_schema.declare(('session', 'debug'), types.Bool(), False, + __name__, 'Debug mode', 'Enable debug output and debug behaviour. Should be set to false in a productive environment.') + +global_schema.declare(('session', 'paths', 'config'), types.Path(), '', + __name__, 'Config path', "The path of the session's main configuration file. Is set automatically and for internal use only.") + +global_schema.declare(('storage', 'config', 'write_through'), types.Bool(), True, + __name__, 'Write-through', "Write the config to its file whenever it changes") + +## EOF ## diff --git a/tagit/config/settings.yaml b/tagit/config/settings.yaml new file mode 100644 index 0000000..e9264d4 --- /dev/null +++ b/tagit/config/settings.yaml @@ -0,0 +1,10 @@ +ui: + standalone: + keytriggers: [] + buttondocks: + navigation_left: [] + navigation_right: [] + filter: [] + status: [] + context: + root: [] diff --git a/tagit/config/types.py b/tagit/config/types.py new file mode 100644 index 0000000..3dc3d38 --- /dev/null +++ b/tagit/config/types.py @@ -0,0 +1,273 @@ +""" + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# tagit imports +from tagit.utils import errors, is_list + +# exports +__all__: typing.Sequence[str] = ( + 'ConfigTypeError', + # types + 'Any', + 'Bool', + 'Dict', + 'Enum', + 'Float', + 'Int', + 'Keybind', + 'List', + 'Numeric', + 'Path', + 'String', + 'Unsigned', + ) + +# TODO: Bounded int or range? (specify lo/hi bounds) +# TODO: File vs. Dir; existence condition? + +## code ## + +# base class + +class ConfigTypeError(TypeError): + """Raised if a type inconsistency is detected.""" + pass + +class ConfigType(object): + """A config type defines a constraint over admissible values in order to + perform a basic verification of user-entered config values. + """ + + # example values + example = '' + + # type description + description = '' + + def __str__(self): + return f'{type(self).__name__}' + + def __repr__(self): + return f'{type(self).__name__}()' + + def __eq__(self, other): + return isinstance(other, type(self)) + + def __hash__(self): + return hash(type(self)) + + def check(self, value): + """Return True if the *value* matches the type.""" + try: + self.backtrack(value, '') + return True + except ConfigTypeError: + return False + + def backtrack(self, value, key): + """Check *value* for errors. + Raises a ConfigTypeError with a detailed message if an inconsistency is detected. + """ + errors.abstract() + +# generic types + +class Any(ConfigType): + example = '1, "a", [1,2,"a"]' + description = 'Any type' + + def backtrack(self, value, key): + # accepts anything + pass + + +class Bool(ConfigType): + example = 'True, False' + description = 'Boolean' + + def backtrack(self, value, key): + if not isinstance(value, bool): + raise ConfigTypeError(f'found {value} in {key}, expected a boolean') + + +class Keybind(ConfigType): + example = '[("a", ["ctrl"], [])]' + description = 'A list of (key, required modifiers, excluded modifiers)-triples' + + def backtrack(self, value, key): + if not is_list(value): + raise ConfigTypeError(f'found {type(value)} in {key}, expected a list of bindings') + + modifiers = {'shift', 'alt', 'ctrl', 'cmd', 'altgr', 'rest', 'all'} + for idx, itm in enumerate(value): + if not is_list(itm) or len(itm) != 3: + raise ConfigTypeError(f'found {itm} in {key}[{idx}], expected a list of three') + + char, inc, exc = itm + if not isinstance(char, str) and \ + not isinstance(char, int) and \ + not isinstance(char, float): + raise ConfigTypeError( + f'found {char} in {key}[{idx}], expected a character or number') + if not is_list(inc) or not set(inc).issubset(modifiers): + mods = ','.join(modifiers) + raise ConfigTypeError(f'found {inc} in {key}[{idx}], expected some of ({mods})') + if not is_list(exc) or not set(exc).issubset(modifiers): + mods = ','.join(modifiers) + raise ConfigTypeError(f'found {exc} in {key}[{idx}], expected some of ({mods})') + + +# numeric types + +class Numeric(ConfigType): + pass + + +class Int(Numeric): + example = '-8, -1, 0, 1, 3' + description = 'Integer number' + + def backtrack(self, value, key): + if not isinstance(value, int): + raise ConfigTypeError(f'found {value} in {key}, expected an integer') + + +class Unsigned(Int): + example = '0, 1, 13, 32' + description = 'Non-negative integer number, including zero' + + def __str__(self): + return 'Unsigned int' + + def backtrack(self, value, key): + if not isinstance(value, int) or value < 0: + raise ConfigTypeError(f'found {value} in {key}, expeced an integer of at least zero') + + +class Float(Numeric): + example = '1.2, 3.4, 5, 6' + description = 'Integer or Decimal number' + + def backtrack(self, value, key): + if not isinstance(value, float) and not isinstance(value, int): + raise ConfigTypeError(f'found {value} in {key}, expected a number') + + +# string types + +class String(ConfigType): + example = '"hello world", "", "foobar"' + description = 'String' + + def backtrack(self, value, key): + if not isinstance(value, str): + raise ConfigTypeError(f'found {value} in {key}, expected a string') + + +class Path(String): + example = '"/tmp", "Pictures/trip", "~/.tagitrc"' + description = 'String, compliant with file system paths' + + +# compound types + +class Enum(ConfigType): + description = 'One out of a predefined set of values' + + @property + def example(self): + return ', '.join(str(o) for o in list(self.options)[:3]) + + def __init__(self, *options): + self.options = set(options[0] if len(options) == 1 and is_list(options[0]) else options) + + def __eq__(self, other): + return super(Enum, self).__eq__(other) and \ + self.options == other.options + + def __hash__(self): + return hash((super(Enum, self).__hash__(), tuple(self.options))) + + def __str__(self): + options = ', '.join(str(itm) for itm in self.options) + return f'One out of ({options})' + + def __repr__(self): + return f'{type(self).__name__}([{self.options}])' + + def backtrack(self, value, key): + try: + if value not in self.options: + raise Exception() + except Exception: + options = ','.join(str(itm) for itm in self.options) + raise ConfigTypeError(f'found {value} in {key}, expected one out of ({options})') + + +class List(ConfigType): + description = 'List of values' + + @property + def example(self): + return f'[{self.item_type.example}]' + + def __init__(self, item_type): + self.item_type = item_type + + def __eq__(self, other): + return super(List, self).__eq__(other) and \ + self.item_type == other.item_type + + def __hash__(self): + return hash((super(List, self).__hash__(), hash(self.item_type))) + + def __str__(self): + return f'List of {str(self.item_type)}' + + def __repr__(self): + return f'{type(self).__name__}({self.item_type})' + + def backtrack(self, value, key): + if not isinstance(value, list) and not isinstance(value, tuple): + raise ConfigTypeError(f'found {type(value)} in {key}, expected list') + for item in value: + self.item_type.backtrack(item, key) + + +class Dict(ConfigType): + example = '{"hello": "world"}; {"hello": 3}; {"hello": [1, 2, 3]}' + description = 'Map of keys/values' + + def __init__(self, key_type, value_type): + self.key_type = key_type + self.value_type = value_type + + def __eq__(self, other): + return super(Dict, self).__eq__(other) and \ + self.key_type == other.key_type and \ + self.value_type == other.value_type + + def __hash__(self): + return hash((super(Dict, self).__hash__(), hash(self.key_type), hash(self.value_type))) + + def __str__(self): + return f'Dict from {self.key_type} to {self.value_type}' + + + def __repr__(self): + return f'{type(self).__name__}({self.key_type}, {self.value_type})' + + def backtrack(self, value, key): + if not isinstance(value, dict): + raise ConfigTypeError(f'found {type(value)} in {key}, expected a dict') + for subkey, subval in value.items(): + self.key_type.backtrack(subkey, str(key) + '.' + str(subkey)) + self.value_type.backtrack(subval, str(key) + '.' + str(subkey)) + +## EOF ## diff --git a/tagit/config/user-defaults.yaml b/tagit/config/user-defaults.yaml new file mode 100644 index 0000000..447e10f --- /dev/null +++ b/tagit/config/user-defaults.yaml @@ -0,0 +1,107 @@ +session: + bsfs: + Graph: + backend: + SparqlStore: {} + user: 'http://example.com/me' + script: + - Search +ui: + standalone: + window_size: [1440, 810] + #maximize: True + keytriggers: + - MoveCursorUp + - MoveCursorDown + - MoveCursorLeft + - MoveCursorRight + - MoveCursorLast + - MoveCursorFirst + - NextPage + - PreviousPage + - ScrollDown + - ScrollUp + - ZoomIn + - ZoomOut + - Select + - SelectAll + - SelectNone + - SelectMulti + - SelectRange + - AddToken + - GoBack + - GoForth + - AddTag + - EditTag + - Search + - ShowSelected + - RemoveSelected + browser: + cols: 4 + rows: 3 + maxcols: 8 + maxrows: 8 + buttondocks: + navigation_left: + - MoveCursorFirst + - PreviousPage + - ScrollUp + navigation_right: + - ScrollDown + - NextPage + - MoveCursorLast + filter: + - GoBack + - GoForth + status: + - ZoomIn + - ZoomOut + sidebar_left: + - AddTag + - EditTag + - ShowSelected + - RemoveSelected + - SelectAll + - SelectNone + - SelectInvert + - SelectSingle + - SelectRange + - SelectMulti + - SelectAdditive + - SelectSubtractive + context: + app: + - ShowHelp + - ShowConsole + - ShowSettings + browser: + - ZoomIn + - ZoomOut + - MoveCursorFirst + - PreviousPage + - ScrollUp + - ScrollDown + - NextPage + - MoveCursorLast + search: + - AddToken + - SortOrder + - ShowSelected + - RemoveSelected + - GoForth + - GoBack + select: + - SelectAll + - SelectNone + - SelectInvert + - SelectSingle + - SelectMulti + - SelectRange + - SelectAdditive + - SelectSubtractive + tagging: + - AddTag + - EditTag + tiledocks: + sidebar_right: + Info: {} diff --git a/tagit/config/utils.py b/tagit/config/utils.py new file mode 100644 index 0000000..948f53a --- /dev/null +++ b/tagit/config/utils.py @@ -0,0 +1,104 @@ +"""Configuration system utilities. + +Part of the tagit module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# standard imports +import typing + +# tagit imports +from tagit.utils import import_all + +# inner-module imports +from . import types + +# exports +__all__: typing.Sequence[str] = ( + 'key_starts_with', + 'schema_key_sort', + 'schema_to_rst', + 'subkey_of', + 'superkey_of', + ) + + +## code ## + +def key_starts_with(key, prefix): + """Return whether a config *key* starts with (is a subclass of) *prefix*.""" + return key[:len(prefix)] == prefix + +def subkey_of(key, pool): + """Return True if *key* is at a lower level than some key in *pool*. + Example: session.debug is a subkey of session. + """ + for sup in pool: + if key_starts_with(key, sup): + return True + return False + +def superkey_of(key, pool): + """Return True if *key* is at a higher level than some key in *pool*. + Example: session is a superkey of session.debug. + """ + for sub in pool: + if key_starts_with(sub, key): + return True + return False + +def schema_key_sort(schema): + """Return a comparison function for sorting schema config or title keys. + To be used in sorted or sort as key function. + + >>> sorted(schema.keys(titles=True), key=schema_keys_sort(schema)) + + """ + def cmp(key): + """Return an unambiguous representation of schema config or title keys.""" + return ('.'.join(key[:-1]) + '..' + key[-1]) \ + if not schema.is_title(key) \ + else ('.'.join(key) + '..') + + return cmp + +def schema_to_rst(schema, print_modules=False, no_import=False): + """Creates a documentation page in ReST of the config schema. + Calling this method with *no_import* set to False imports all + tagit submodules. + """ + # import all modules + if not no_import: + import tagit + import_all(tagit, exclude={'.*\.external'}) + + header = '=-^~"' + + known_sections = set() + for key in sorted(schema.keys(titles=True), key=schema_key_sort(schema)): + # print headings + for idx, sec in enumerate(key): + heading = '.'.join(key[:idx+1]) + if heading not in known_sections: + print('') + print(heading) + print(header[idx] * len(heading)) + known_sections.add(heading) + + if schema.is_title(key): + print(schema.get_title(key).description + '\n') + + else: + print(schema[key].description + '\n') + + print(f':Format: {str(schema[key].type)} ({schema[key].example})') + print(f':Default: {schema[key].default}') + + if isinstance(schema[key].type, types.Enum): + print(f':Options: {schema[key].type.options}') + + if print_modules: + modules = ', '.join(f'`{str(m)}`_' for m in schema[key].modules) + print(f':Modules: {modules}') + +## EOF ## |