aboutsummaryrefslogtreecommitdiffstats
path: root/tagit
diff options
context:
space:
mode:
authorMatthias Baumgartner <dev@igsor.net>2023-01-06 12:20:22 +0100
committerMatthias Baumgartner <dev@igsor.net>2023-01-06 12:20:22 +0100
commit079b4da93ea336b5bcc801cfd64c310aa7f8ddee (patch)
tree9c9a1cf7cbb9d71ba8dcce395996a1af3db790e2 /tagit
parent0ba7a15c124d3a738a45247e78381dd56f7f1fa9 (diff)
downloadtagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.tar.gz
tagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.tar.bz2
tagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.zip
config early port (test still fails)
Diffstat (limited to 'tagit')
-rw-r--r--tagit/config/__init__.py25
-rw-r--r--tagit/config/loader.py83
-rw-r--r--tagit/config/schema.py283
-rw-r--r--tagit/config/settings.json70
-rw-r--r--tagit/config/settings.py473
-rw-r--r--tagit/config/types.py273
-rw-r--r--tagit/config/user-defaults.json142
-rw-r--r--tagit/config/utils.py104
-rw-r--r--tagit/utils/__init__.py1
-rw-r--r--tagit/utils/errors.py52
-rw-r--r--tagit/utils/shared.py63
11 files changed, 1569 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..489b063
--- /dev/null
+++ b/tagit/config/loader.py
@@ -0,0 +1,83 @@
+"""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.json')
+
+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.json'),
+ ]
+
+# 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
+ first_start = False
+ user_config = os.path.expanduser(os.path.join('~', TAGITRC))
+ if os.path.exists(DEFAULT_USER_CONFIG) and not os.path.exists(user_config):
+ first_start = True
+ 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))
+ # set first start according to previous user config existence
+ cfg.set(('session', 'first_start'), first_start)
+ 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.json b/tagit/config/settings.json
new file mode 100644
index 0000000..e0bb3cf
--- /dev/null
+++ b/tagit/config/settings.json
@@ -0,0 +1,70 @@
+{
+ "ui": {
+ "standalone": {
+ "keytriggers": [
+ "ClipboardCopy",
+ "ClipboardPaste",
+ "CreateGroup",
+ "DissolveGroup",
+ "AddToGroup",
+ "MoveCursorUp",
+ "MoveCursorDown",
+ "MoveCursorLeft",
+ "MoveCursorRight",
+ "MoveCursorLast",
+ "MoveCursorFirst",
+ "NextPage",
+ "PreviousPage",
+ "ScrollDown",
+ "ScrollUp",
+ "ZoomIn",
+ "ZoomOut",
+ "Select",
+ "SelectAll",
+ "SelectNone",
+ "SelectMulti",
+ "SelectRange",
+ "AddToken",
+ "GoBack",
+ "GoForth",
+ "SearchByAddressOnce",
+ "AddTag",
+ "EditTag",
+ "OpenGroup",
+ "RepresentGroup",
+ "Search",
+ "ShowSelected",
+ "RemoveSelected",
+ "OpenExternal",
+ "ShowHelp"
+ ],
+ "buttondocks": {
+ "filter": [
+ "AddToken",
+ "GoBack",
+ "GoForth"
+ ],
+ "navigation_left": [
+ "MoveCursorFirst",
+ "PreviousPage",
+ "ScrollUp"
+ ],
+ "navigation_right": [
+ "ScrollDown",
+ "NextPage",
+ "MoveCursorLast"
+ ],
+ "status": [
+ "RotateLeft",
+ "DeleteObject",
+ "RotateRight"
+ ]
+ },
+ "context": {
+ "root": [
+ "CloseSessionAndExit"
+ ]
+ }
+ }
+ }
+}
diff --git a/tagit/config/settings.py b/tagit/config/settings.py
new file mode 100644
index 0000000..21ab594
--- /dev/null
+++ b/tagit/config/settings.py
@@ -0,0 +1,473 @@
+"""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
+
+# 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)
+ else:
+ config = json.loads(source)
+ elif isinstance(source, io.TextIOBase): # opened file
+ config = json.load(source)
+ 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/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.json b/tagit/config/user-defaults.json
new file mode 100644
index 0000000..b76ef2b
--- /dev/null
+++ b/tagit/config/user-defaults.json
@@ -0,0 +1,142 @@
+{
+ "session": {
+ "first_start": false,
+ "paths": {
+ "searchlog": "~/.tagit.log"
+ }
+ },
+ "storage": {
+ "index": {
+ "preview_size": [
+ 50,
+ 200,
+ 400
+ ]
+ }
+ },
+ "ui": {
+ "standalone": {
+ "window_size": "1024x768",
+ "browser": {
+ "maxrows": 8,
+ "maxcols": 8
+ },
+ "buttondocks": {
+ "sidebar_left": [
+ "Menu",
+ "ShowDashboard",
+ "AddTag",
+ "EditTag",
+ "CreateGroup",
+ "DissolveGroup",
+ "SelectAll",
+ "SelectNone",
+ "SelectInvert",
+ "SelectAdditive",
+ "SelectSubtractive",
+ "SelectSingle",
+ "SelectMulti",
+ "SelectRange"
+ ]
+ },
+ "tabs": {
+ "max": 2
+ },
+ "tiledocks": {
+ "dashboard": {
+ "Buttons": {
+ "buttons": [
+ "ShowBrowsing",
+ "CreateSession",
+ "CreateTempSession",
+ "LoadSession",
+ "ReloadSession",
+ "ImportObjects",
+ "SaveSession",
+ "SaveSessionAs",
+ "ItemExport",
+ "UpdateSelectedObjects",
+ "SyncSelectedObjects",
+ "ShowHelp",
+ "ShowSettings"
+ ]
+ },
+ "LibSummary": {},
+ "Hints": {},
+ "TagHistogram": {},
+ "Tagcloud": {},
+ "Searchtree": {}
+ },
+ "sidebar_right": {
+ "Info": {},
+ "CursorTags": {},
+ "Venn": {}
+ }
+ },
+ "search": {
+ "sort_blacklist": [
+ "entity",
+ "flash",
+ "latitude",
+ "longitude",
+ "mime",
+ "author",
+ "camera",
+ "attributes"
+ ]
+ },
+ "context": {
+ "app": [
+ "ShowSettings",
+ "ShowHelp",
+ "ShowConsole"
+ ],
+ "session": [
+ "SaveSession",
+ "SaveSessionAs",
+ "ItemExport",
+ "ImportObjects"
+ ],
+ "search": [
+ "ShowSelected",
+ "RemoveSelected"
+ ],
+ "browser": [
+ "ZoomIn",
+ "ZoomOut"
+ ],
+ "select": [
+ "SelectAll",
+ "SelectNone",
+ "SelectInvert",
+ "SelectSingle",
+ "SelectMulti",
+ "SelectRange",
+ "SelectAdditive",
+ "SelectSubtractive"
+ ],
+ "clipboard": [
+ "ClipboardCopy",
+ "ClipboardPaste"
+ ],
+ "tagging": [
+ "AddTag",
+ "EditTag",
+ "SetRank1",
+ "SetRank3",
+ "SetRank5"
+ ],
+ "grouping": [
+ "CreateGroup",
+ "DissolveGroup",
+ "AddToGroup",
+ "RepresentGroup",
+ "RemoveFromGroup"
+ ],
+ "root": [
+ "CloseSessionAndExit"
+ ]
+ }
+ }
+ }
+}
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 ##
diff --git a/tagit/utils/__init__.py b/tagit/utils/__init__.py
index d5a8efe..d143034 100644
--- a/tagit/utils/__init__.py
+++ b/tagit/utils/__init__.py
@@ -9,6 +9,7 @@ import typing
# inner-module imports
from . import bsfs
+from .shared import * # FIXME: port properly
# exports
__all__: typing.Sequence[str] = (
diff --git a/tagit/utils/errors.py b/tagit/utils/errors.py
new file mode 100644
index 0000000..1bed670
--- /dev/null
+++ b/tagit/utils/errors.py
@@ -0,0 +1,52 @@
+"""Module-wide errors.
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2018
+"""
+# exports
+__all__ = (
+ 'EmptyFileError',
+ 'LoaderError',
+ 'NotAFileError',
+ 'ProgrammingError',
+ 'UserError',
+ 'abstract',
+ )
+
+
+## code ##
+
+def abstract():
+ """Marks that a method has to be implemented in a child class."""
+ raise NotImplementedError('abstract method that must be implemented in a subclass')
+
+class ProgrammingError(Exception):
+ """Reached a program state that shouldn't be reachable."""
+ pass
+
+class UserError(ValueError):
+ """Found an illegal value that was specified directly by the user."""
+ pass
+
+class NotAFileError(OSError):
+ """A file-system object is not a regular file."""
+ pass
+
+class EmptyFileError(OSError):
+ """A file is unexpectedly empty."""
+ pass
+
+class LoaderError(Exception):
+ """Failed to load or initialize a critical data structure."""
+ pass
+
+class ParserFrontendError(Exception):
+ """Generic parser frontend error."""
+ pass
+
+class ParserBackendError(Exception):
+ """Generic parser backend error."""
+ pass
+
+## EOF ##
diff --git a/tagit/utils/shared.py b/tagit/utils/shared.py
new file mode 100644
index 0000000..13ffd2a
--- /dev/null
+++ b/tagit/utils/shared.py
@@ -0,0 +1,63 @@
+# FIXME: port properly!
+"""Shared functionality.
+
+Part of the tagit module.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import logging
+import pkgutil
+import re
+import typing
+
+# exports
+__all__ = ('import_all', )
+
+
+## code ##
+
+# exports
+__all__: typing.Sequence[str] = (
+ 'fst',
+ 'is_list',
+ 'import_all',
+ )
+
+
+## code ##
+
+logger = logging.getLogger(__name__)
+
+fst = lambda lst: lst[0]
+
+def is_list(cand):
+ """Return true if *cand* is a list, a set, or a tuple"""
+ return isinstance(cand, list) or isinstance(cand, set) or isinstance(cand, tuple)
+
+def import_all(module, exclude=None, verbose=False):
+ """Recursively import all submodules of *module*.
+ *exclude* is a set of submodule names which will
+ be omitted. With *verbose*, all imports are logged
+ with level info. Returns all imported modules.
+
+ >>> import tagit
+ >>> import_all(tagit, exclude={'tagit.shared.external'})
+
+ """
+ exclude = set([] if exclude is None else exclude)
+ imports = []
+ for importer, name, ispkg in pkgutil.iter_modules(module.__path__, module.__name__ + '.'):
+ if ispkg and all(re.match(exl, name) is None for exl in exclude):
+ if verbose:
+ logger.info(f'importing: {name}')
+ try:
+ module = __import__(name, fromlist='dummy')
+ imports.append(module)
+ imports += import_all(module, exclude, verbose)
+ except Exception as e:
+ logger.error(f'importing: {name}')
+
+ return imports
+
+## EOF ##