aboutsummaryrefslogtreecommitdiffstats
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
parent0ba7a15c124d3a738a45247e78381dd56f7f1fa9 (diff)
downloadtagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.tar.gz
tagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.tar.bz2
tagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.zip
config early port (test still fails)
-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
-rw-r--r--test/__init__.py0
-rw-r--r--test/config/__init__.py0
-rw-r--r--test/config/test_schema.py284
-rw-r--r--test/config/test_settings.py903
-rw-r--r--test/config/test_types.py251
16 files changed, 3007 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 ##
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/__init__.py
diff --git a/test/config/__init__.py b/test/config/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/config/__init__.py
diff --git a/test/config/test_schema.py b/test/config/test_schema.py
new file mode 100644
index 0000000..9e3d3b7
--- /dev/null
+++ b/test/config/test_schema.py
@@ -0,0 +1,284 @@
+"""
+
+Part of the tagit test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import unittest
+
+# tagit imports
+from tagit.config import types
+from tagit.utils import errors
+
+# objects to test
+from tagit.config.schema import ConfigSchema, ConfigKey, ConfigTitle, IncompatibleTypes
+
+
+## code ##
+
+def _config_title_check(self, key0, key1):
+ self.assertEqual(key0, key1)
+ self.assertEqual(key0._title, key1._title)
+ self.assertEqual(key0._description, key1._description)
+ self.assertSetEqual(key0._modules, key1._modules)
+
+def _config_key_check(self, key0, key1):
+ self.assertEqual(key0, key1)
+ self.assertEqual(key0._title, key1._title)
+ self.assertEqual(key0._description, key1._description)
+ self.assertSetEqual(key0._modules, key1._modules)
+ self.assertSetEqual(key0._examples, key1._examples)
+
+class TestConfigSchema(unittest.TestCase):
+ def test_collection(self):
+ # getitem, call
+ # contains
+ # iter
+ # len
+ # keys
+ pass
+
+ def test_title(self):
+ schema = ConfigSchema()
+ # recognize title
+ schema.declare_title(('some', 'key'), 'module', 'title', 'description')
+ schema.declare(('other', 'key'), types.Int(), 0,
+ 'module', 'title', 'description', 'example')
+ self.assertTrue(schema.is_title(('some', 'key')))
+ self.assertFalse(schema.is_title(('other', 'key')))
+ _config_title_check(self, schema.get_title(('some', 'key')),
+ ConfigTitle(('some', 'key'), 'title', 'module', 'description'))
+ self.assertRaises(KeyError, schema.get_title, ('other', 'key'))
+ # config takes precedence
+ schema.declare(('some', 'key'), types.Int(), 0,
+ 'module', 'title', 'description', 'example')
+ self.assertFalse(schema.is_title(('some', 'key')))
+ # can still retrieve the title
+ _config_title_check(self, schema.get_title(('some', 'key')),
+ ConfigTitle(('some', 'key'), 'title', 'module', 'description'))
+
+ def test_declare(self):
+ schema = ConfigSchema()
+ # keys can be declared
+ schema.declare(('some', 'key'), types.Int(), 0,
+ 'module', 'title', 'description', 'example')
+ self.assertSetEqual(set(schema.config.keys()), {('some', 'key')})
+ _config_key_check(self, schema.config[('some', 'key')],
+ ConfigKey(('some', 'key'), types.Int(), 0,
+ 'module', 'title', 'description', 'example'))
+ # double insert with identical signature is accepted
+ schema.declare(('some', 'key'), types.Int(), 0,
+ 'module', 'title', 'description', 'example')
+ self.assertSetEqual(set(schema.config.keys()), {('some', 'key')})
+ _config_key_check(self, schema.config[('some', 'key')],
+ ConfigKey(('some', 'key'), types.Int(), 0,
+ 'module', 'title', 'description', 'example'))
+ # additional info is accepted
+ schema.declare(('some', 'key'), types.Int(), 0,
+ 'other_module', 'other_title', 'other_description', 'other_example')
+ self.assertSetEqual(set(schema.config.keys()), {('some', 'key')})
+ ck = ConfigKey(('some', 'key'), types.Int(), 0,
+ 'module', 'other_title', 'other_description', 'example')
+ ck._examples.add('other_example')
+ ck._modules.add('other_module')
+ _config_key_check(self, schema.config[('some', 'key')], ck)
+
+ # empty key is rejected
+ self.assertRaises(errors.ProgrammingError, schema.declare, [], types.Int(), 0)
+ # empty type is rejected
+ self.assertRaises(AttributeError, schema.declare, ('foo', ), None, None)
+ # invalid defaults are rejected
+ self.assertRaises(errors.ProgrammingError, schema.declare, ('foo', ), types.Unsigned(), -1)
+ self.assertRaises(errors.ProgrammingError, schema.declare, ('foo', ), types.Int(), 'abc')
+ self.assertRaises(errors.ProgrammingError, schema.declare, ('foo', ), types.String(), 123)
+ self.assertRaises(errors.ProgrammingError, schema.declare, ('foo', ), types.Enum('foo'), 'bar')
+ # double insert with different signature is rejected
+ self.assertRaises(IncompatibleTypes, schema.declare, ('some', 'key'), types.Unsigned(), 0)
+ self.assertRaises(IncompatibleTypes, schema.declare, ('some', 'key'), types.Int(), 2)
+
+ def test_declare_title(self):
+ schema = ConfigSchema()
+ # titles can be declared
+ schema.declare_title(('some', 'key'), 'module', 'title', 'description')
+ self.assertSetEqual(set(schema.titles.keys()), {('some', 'key')})
+ _config_title_check(self, schema.titles[('some', 'key')],
+ ConfigTitle(('some', 'key'),
+ 'title', 'module', 'description'))
+ # double insert is accepted
+ schema.declare_title(('some', 'key'), 'module', 'title', 'description')
+ self.assertSetEqual(set(schema.titles.keys()), {('some', 'key')})
+ _config_title_check(self, schema.titles[('some', 'key')], ConfigTitle(('some', 'key'),
+ 'title', 'module', 'description'))
+ # additional info is accepted
+ schema.declare_title(('some', 'key'), 'other_module', 'other_title', 'other_description')
+ self.assertSetEqual(set(schema.titles.keys()), {('some', 'key')})
+ ck = ConfigTitle(('some', 'key'), 'other_title', 'module', 'other_description')
+ ck._modules.add('other_module')
+ _config_title_check(self, schema.titles[('some', 'key')], ck)
+ # empty key is rejected
+ self.assertRaises(errors.ProgrammingError, schema.declare_title, [], types.Int(), 0)
+ # title and config key can exist in parallel
+ schema.declare(('other', 'key'), types.Int(), 0)
+ self.assertSetEqual(set(schema.config.keys()), {('other', 'key')})
+ _config_key_check(self, schema.config[('other', 'key')],
+ ConfigKey(('other', 'key'), types.Int(), 0))
+ schema.declare_title(('other', 'key'), 'module', 'title', 'description')
+ self.assertIn(('other', 'key'), set(schema.titles.keys()))
+ _config_title_check(self, schema.titles[('other', 'key')], ConfigTitle(('other', 'key'),
+ 'title', 'module', 'description'))
+
+
+class TestConfigTitle(unittest.TestCase):
+ def test_magicks(self):
+ ck = ConfigTitle(('some', 'key'), 'title', 'module', 'description')
+ # representation
+ self.assertEqual(repr(ck), "ConfigTitle(('some', 'key'), title)")
+ # comparison
+ self.assertEqual(ck, ConfigTitle(('some', 'key')))
+ self.assertEqual(hash(ck), hash(ConfigTitle(('some', 'key'))))
+ self.assertNotEqual(ck, ConfigTitle(('other', 'key')))
+
+ def test_properties(self):
+ ck = ConfigTitle(('some', 'key'), 'title', 'module', 'some_description')
+ # key can't be overwritten
+ self.assertRaises(AttributeError, ck.__setattr__, 'key', ('other', 'key'))
+ # modules
+ ck.modules = 'other_module'
+ self.assertSetEqual(ck.modules, {'module', 'other_module'})
+ ck.modules = None
+ self.assertSetEqual(ck.modules, {'module', 'other_module'})
+ ck.modules = ''
+ self.assertSetEqual(ck.modules, {'module', 'other_module'})
+ # title
+ ck.title = 'other_title'
+ self.assertEqual(ck.title, 'other_title')
+ ck.title = None
+ self.assertEqual(ck.title, 'other_title')
+ ck.title = ''
+ self.assertEqual(ck.title, 'other_title')
+ # description
+ ck.description = 'other_description'
+ self.assertEqual(ck.description, 'other_description')
+ ck.description = None
+ self.assertEqual(ck.description, 'other_description')
+ ck.description = ''
+ self.assertEqual(ck.description, 'other_description')
+
+ def test_tree(self):
+ self.assertEqual(ConfigTitle(('some', 'path', 'to', 'a', 'key')).branch,
+ ('some', 'path', 'to', 'a'))
+ self.assertEqual(ConfigTitle(('some', 'path', 'to', 'a', 'key')).leaf,
+ 'key')
+ self.assertEqual(ConfigTitle(('somekey', )).branch,
+ tuple())
+ self.assertEqual(ConfigTitle(('somekey', )).leaf,
+ 'somekey')
+
+
+class TestConfigKey(unittest.TestCase):
+ def test_magicks(self):
+ ck = ConfigKey(('some', 'key'), types.Int(), 0,
+ 'module', 'title', 'some_description', 'some_example')
+ ck.example = 'other_example'
+ ck.modules = 'other_module'
+ # representation
+ self.assertEqual(repr(ck), "ConfigKey(('some', 'key'), Int, 0)")
+ # comparison
+ self.assertEqual(ck, ConfigKey(('some', 'key'), types.Int(), 0))
+ self.assertEqual(hash(ck), hash(ConfigKey(('some', 'key'), types.Int(), 0)))
+ self.assertNotEqual(ck, ConfigKey(('some', 'key'), types.Int(), 1))
+ self.assertNotEqual(ck, ConfigKey(('some', 'key'), types.Unsigned(), 0))
+ self.assertNotEqual(ck, ConfigKey(('other', 'key'), types.Int(), 0))
+
+ def test_properties(self):
+ ck = ConfigKey(('some', 'key'), types.Int(), 0,
+ 'module', 'title', 'some_description', 'some_example')
+ self.assertEqual(ck.key, ('some', 'key'))
+ self.assertEqual(ck.type, types.Int())
+ self.assertEqual(ck.default, 0)
+ self.assertSetEqual(ck.modules, {'module'})
+ self.assertEqual(ck.title, 'title')
+ self.assertEqual(ck.description, 'some_description')
+ self.assertEqual(ck.example, 'some_example')
+
+ # key, type, default can't be overwritten
+ self.assertRaises(AttributeError, ck.__setattr__, 'key', ('other', 'key'))
+ self.assertRaises(AttributeError, ck.__setattr__, 'type', types.Unsigned())
+ self.assertRaises(AttributeError, ck.__setattr__, 'default', 123)
+ # modules
+ ck.modules = 'other_module'
+ self.assertSetEqual(ck.modules, {'module', 'other_module'})
+ ck.modules = None
+ self.assertSetEqual(ck.modules, {'module', 'other_module'})
+ ck.modules = ''
+ self.assertSetEqual(ck.modules, {'module', 'other_module'})
+ # title
+ ck.title = 'other_title'
+ self.assertEqual(ck.title, 'other_title')
+ ck.title = None
+ self.assertEqual(ck.title, 'other_title')
+ ck.title = ''
+ self.assertEqual(ck.title, 'other_title')
+ # description
+ ck.description = 'other_description'
+ self.assertEqual(ck.description, 'other_description')
+ ck.description = None
+ self.assertEqual(ck.description, 'other_description')
+ ck.description = ''
+ self.assertEqual(ck.description, 'other_description')
+ # example
+ ck.example = 'other_example'
+ ex = ck.example
+ self.assertTrue('other_example' in ex)
+ self.assertTrue('some_example' in ex)
+ ck.example = None
+ self.assertEqual(ck.example, ex)
+ ck.example = ''
+ self.assertEqual(ck.example, ex)
+ # type example if unspecified
+ self.assertEqual(ConfigKey(('some', 'key'), types.Int(), 0).example, types.Int().example)
+
+ def test_checks(self):
+ # check
+ self.assertTrue(ConfigKey(('some', 'key'), types.Int(), 0).check(0))
+ self.assertFalse(ConfigKey(('some', 'key'), types.Int(), 0).check(1.23))
+ self.assertFalse(ConfigKey(('some', 'key'), types.Int(), 0).check('foobar'))
+ self.assertTrue(ConfigKey(('some', 'key'), types.Unsigned(), 0).check(0))
+ self.assertFalse(ConfigKey(('some', 'key'), types.Unsigned(), 0).check(-1))
+ self.assertTrue(ConfigKey(('some', 'key'), types.String(), 0).check('foobar'))
+ self.assertFalse(ConfigKey(('some', 'key'), types.String(), 0).check(['foo', 'bar']))
+
+ # backtrack
+ self.assertTrue(ConfigKey(['somekey'], types.Int(), 0).backtrack(0))
+ self.assertTrue(ConfigKey(['somekey'], types.Int(), 0).backtrack(-1))
+ self.assertTrue(ConfigKey(['somekey'], types.Int(), 0).backtrack(123))
+ self.assertRaises(types.ConfigTypeError,
+ ConfigKey(['somekey'], types.Int(), 0).backtrack, 1.23)
+ self.assertRaises(types.ConfigTypeError,
+ ConfigKey(['somekey'], types.Int(), 0).backtrack, 'foobar')
+ self.assertTrue(ConfigKey(['somekey'], types.Unsigned(), 0).backtrack(0))
+ self.assertRaises(types.ConfigTypeError,
+ ConfigKey(['somekey'], types.Unsigned(), 0).backtrack, -1)
+ self.assertTrue(ConfigKey(['somekey'], types.String(), 0).backtrack('foobar'))
+ self.assertRaises(types.ConfigTypeError,
+ ConfigKey(['somekey'], types.String(), 0).backtrack, ['foo', 'bar'])
+
+ def test_tree(self):
+ self.assertEqual(ConfigKey(('some', 'path', 'to', 'a', 'key'), types.Int(), 0).branch,
+ ('some', 'path', 'to', 'a'))
+ self.assertEqual(ConfigKey(('some', 'path', 'to', 'a', 'key'), types.Int(), 0).leaf,
+ 'key')
+ self.assertEqual(ConfigKey(('somekey', ), types.Int(), 0).branch,
+ tuple())
+ self.assertEqual(ConfigKey(('somekey', ), types.Int(), 0).leaf,
+ 'somekey')
+
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##
diff --git a/test/config/test_settings.py b/test/config/test_settings.py
new file mode 100644
index 0000000..d7ce7f8
--- /dev/null
+++ b/test/config/test_settings.py
@@ -0,0 +1,903 @@
+"""Test settings loading and access.
+
+Part of the tagit test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# standard imports
+import io
+import json
+import os
+import tempfile
+import unittest
+
+# tagit imports
+from tagit.config import Settings, ConfigError, types
+
+# objects to test
+from tagit.config.schema import ConfigSchema
+
+
+## code ##
+
+class TestSettings(unittest.TestCase):
+ def setUp(self):
+ # example schema
+ self.schema = ConfigSchema()
+ self.schema.declare(('ui', 'standalone', 'browser', 'cols'), types.Unsigned(), 3)
+ self.schema.declare(('session', 'paths', 'library'), types.Path(), '')
+ self.schema.declare(('session', 'paths', 'config'), types.Path(), '')
+ self.schema.declare(('extraction', 'constant', 'enabled'), types.Bool(), False)
+
+ # example config
+ self.cfg = Settings(schema=self.schema, data={
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'messaging', 'verbose'): 8,
+ })
+
+ def test_retrieval(self):
+ # get configured
+ self.assertEqual('/path/to/lib', self.cfg('session', 'paths', 'library'))
+ # get default
+ self.assertEqual(3, self.cfg('ui', 'standalone', 'browser', 'cols'))
+ # get invalid
+ self.assertRaises(KeyError, self.cfg, 'ui', 'standalone', 'browser', 'rows')
+ # get unknown
+ self.assertEqual(8, self.cfg('session', 'messaging', 'verbose'))
+ self.assertRaises(KeyError, self.cfg, 'session', 'messaging', 'debug')
+ # get with default
+ self.assertEqual('/path/to/lib', self.cfg('session', 'paths', 'library', default='foo'))
+ self.assertEqual(8, self.cfg('session', 'messaging', 'verbose', default=12))
+ self.assertEqual(5, self.cfg('ui', 'standalone', 'browser', 'cols', default=5))
+ self.assertEqual(3.14, self.cfg('ui', 'standalone', 'browser', 'rows', default=3.14))
+ # get if invalid was configured
+ cfg = Settings(schema=self.schema, data={
+ ('session', 'paths', 'library'): 123,
+ ('session', 'messaging', 'verbose'): 8,
+ })
+ self.assertEqual(8, cfg('session', 'messaging', 'verbose'))
+ self.assertEqual('foobar', cfg('session', 'paths', 'library', default='foobar'))
+ self.assertEqual('', cfg('session', 'paths', 'library'))
+ # test aliases
+ self.assertEqual('/path/to/lib', self.cfg['session', 'paths', 'library'])
+ self.assertEqual('/path/to/lib', self.cfg[('session', 'paths', 'library')])
+
+ def test_view(self):
+ # retrieve view
+ sub = self.cfg('ui', 'standalone', 'browser')
+ self.assertListEqual(list(sub), [
+ ('cols', )])
+ # set in original affects view
+ self.assertEqual(3, sub('cols'))
+ self.cfg.set(('ui', 'standalone', 'browser', 'cols'), 4)
+ self.assertEqual(4, sub('cols'))
+ # unset in original affects view
+ self.cfg.unset('ui', 'standalone', 'browser', 'cols')
+ self.assertEqual(3, sub('cols'))
+ # set in view affects original
+ sub.set(('cols', ), 5)
+ self.assertEqual(5, sub('cols'))
+ self.assertEqual(5, self.cfg('ui', 'standalone', 'browser', 'cols'))
+ # unset in view affects original
+ sub.unset('cols')
+ self.assertEqual(3, sub('cols'))
+ self.assertEqual(3, self.cfg('ui', 'standalone', 'browser', 'cols'))
+ # has
+ self.assertIn(('cols', ), sub)
+ self.assertNotIn(('rows', ), sub)
+ self.assertNotIn(('ui', 'standalone', 'browser'), sub)
+ # unspecified view
+ sub = self.cfg('session', 'messaging')
+ self.assertListEqual(list(sub), [
+ ('verbose', )])
+ # upper branch view
+ sub = self.cfg('session')
+ self.assertCountEqual(list(sub), [
+ ('paths', 'library'),
+ ('paths', 'config'),
+ ('messaging', 'verbose')])
+ self.assertEqual('/path/to/lib', sub('paths', 'library'))
+ self.assertEqual(8, sub('messaging', 'verbose'))
+ self.assertRaises(KeyError, sub, 'session', 'paths', 'library')
+ # defaults
+ sub = self.cfg('ui', 'standalone')
+ self.assertCountEqual(list(sub), [
+ ('browser', 'cols')])
+ # aliases
+ sub = self.cfg['session']
+ self.assertCountEqual(list(sub), [
+ ('paths', 'library'),
+ ('paths', 'config'),
+ ('messaging', 'verbose')
+ ])
+
+ def test_modify_value(self):
+ # knowns
+ # overwrite default
+ self.cfg.set(('ui', 'standalone', 'browser', 'cols'), 4)
+ self.assertEqual(4, self.cfg('ui', 'standalone', 'browser', 'cols'))
+ # overwrite configured
+ self.cfg.set(('ui', 'standalone', 'browser', 'cols'), 5)
+ self.assertEqual(5, self.cfg('ui', 'standalone', 'browser', 'cols'))
+ # remove configured
+ self.cfg.unset('ui', 'standalone', 'browser', 'cols')
+ self.assertEqual(3, self.cfg('ui', 'standalone', 'browser', 'cols'))
+ # remove default
+ self.cfg.unset('ui', 'standalone', 'browser', 'cols')
+ self.assertEqual(3, self.cfg('ui', 'standalone', 'browser', 'cols'))
+ # overwrite with invalid
+ self.assertRaises(TypeError, self.cfg.set, ('ui', 'standalone', 'browser', 'cols'), 'hello')
+ # set to default
+ self.cfg.set(('session', 'paths', 'library'), '')
+ self.assertEqual('', self.cfg('session', 'paths', 'library'))
+ self.assertNotIn(('session', 'paths', 'library'), self.cfg.config)
+
+ # unknowns
+ # overwrite unknown
+ self.cfg.set(('session', 'messaging', 'verbose'), 1)
+ self.assertEqual(1, self.cfg('session', 'messaging', 'verbose'))
+ # overwrite unknonw with different type
+ self.cfg.set(('session', 'messaging', 'verbose'), 'hello')
+ self.assertEqual('hello', self.cfg('session', 'messaging', 'verbose'))
+ # remove unknown
+ self.cfg.unset('session', 'messaging', 'verbose')
+ self.assertRaises(KeyError, self.cfg, 'session', 'messaging', 'verbose')
+
+ # define new unknown
+ self.cfg.set(('storage', 'library', 'autosave'), 15)
+ self.assertEqual(15, self.cfg('storage', 'library', 'autosave'))
+ # overwrite new unknown
+ self.cfg.set(('storage', 'library', 'autosave'), 5)
+ self.assertEqual(5, self.cfg('storage', 'library', 'autosave'))
+ # overwrite new unknown with different type
+ self.cfg.set(('storage', 'library', 'autosave'), 'hello')
+ self.assertEqual('hello', self.cfg('storage', 'library', 'autosave'))
+ # remove unknown
+ self.cfg.unset('storage', 'library', 'autosave')
+ self.assertRaises(KeyError, self.cfg, 'storage', 'library', 'autosave')
+ # remove invalid
+ self.cfg.unset('storage', 'library', 'autosave')
+ self.assertRaises(KeyError, self.cfg, 'storage', 'library', 'autosave')
+
+ # alias
+ self.cfg['storage', 'library', 'autosave'] = 12
+ self.assertEqual(12, self.cfg('storage', 'library', 'autosave'))
+ del self.cfg['storage', 'library', 'autosave']
+ self.assertRaises(KeyError, self.cfg, 'storage', 'library', 'autosave')
+
+ def test_set_branch(self):
+ # knowns
+ cfg = Settings(schema=self.schema)
+ cfg.set(('session', ), {
+ 'paths': {'library': '/path/to/other'},
+ 'messaging': {'debug': True}})
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'library'): '/path/to/other',
+ ('session', 'messaging', 'debug'): True,
+ })
+ cfg.set(('session', 'paths'), {
+ 'library': '',
+ 'config': '/path/to/config',
+ 'numerical': '/path/to/num',
+ })
+ self.assertDictEqual(cfg.config, {
+ # library is omitted because it's the default
+ ('session', 'paths', 'config'): '/path/to/config',
+ ('session', 'paths', 'numerical'): '/path/to/num',
+ ('session', 'messaging', 'debug'): True, # keey rest
+ })
+ # error: value
+ self.assertRaises(TypeError, cfg.set, ('session', ), {
+ 'paths': {'library': 1234},
+ 'messaging': {'debug': True}
+ })
+ # error: subkey
+ self.assertRaises(TypeError, cfg.set, ('session', ), {
+ 'paths': 123,
+ 'messaging': {'debug': True}
+ })
+ # error: superkey
+ self.assertRaises(TypeError, cfg.set, ('session', ), {
+ 'paths': {'library': {'path': '/path/to/lib'}},
+ 'messaging': {'debug': True}
+ })
+
+ # unknowns
+ cfg = Settings(schema=self.schema)
+ cfg.set(('storage', ), {
+ 'library': {'autosave': 10, 'write_through': True},
+ 'index': {'preview_size': [10, 20, 30]}})
+ self.assertDictEqual(cfg.config, {
+ ('storage', 'library', 'autosave'): 10,
+ ('storage', 'library', 'write_through'): True,
+ ('storage', 'index', 'preview_size'): [10, 20, 30],
+ })
+ # unknowns with previous keys
+ cfg.set(('storage', ), {
+ 'library': {'autoindex': 20, 'autosave': 20},
+ 'numerical': {'write_through': False}})
+ self.assertDictEqual(cfg.config, {
+ ('storage', 'library', 'autosave'): 20, # change value
+ ('storage', 'library', 'autoindex'): 20, # add key
+ ('storage', 'library', 'write_through'): True, # keep key
+ ('storage', 'numerical', 'write_through'): False, # add key
+ ('storage', 'index', 'preview_size'): [10, 20, 30], # keep key
+ })
+ # error: superkey
+ self.assertRaises(TypeError, cfg.set, ('storage', ), {
+ 'library': {'autoindex': {'autosave': 20}},
+ 'numerical': {'write_through': True}})
+ # error: subkey
+ self.assertRaises(TypeError, cfg.set, ('storage', ), {
+ 'library': 123,
+ 'numerical': {'write_through': True}})
+
+ # alias
+ cfg['session'] = {
+ 'paths': {'library': '', 'numerical': 'otherpath'},
+ 'messaging': {'verbose': 3, 'debug': False}
+ }
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'numerical'): 'otherpath',
+ ('session', 'messaging', 'verbose'): 3,
+ ('session', 'messaging', 'debug'): False,
+ # keep the rest as-is
+ ('storage', 'library', 'autosave'): 20, # change value
+ ('storage', 'library', 'autoindex'): 20, # add key
+ ('storage', 'library', 'write_through'): True, # keep key
+ ('storage', 'numerical', 'write_through'): False, # add key
+ ('storage', 'index', 'preview_size'): [10, 20, 30], # keep key
+ })
+ cfg[('session', )] = {
+ 'paths': {'library': '/path/to/somewhere', 'numerical': 'mypath'},
+ 'messaging': {'debug': True}
+ }
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'library'): '/path/to/somewhere',
+ ('session', 'paths', 'numerical'): 'mypath',
+ ('session', 'messaging', 'debug'): True,
+ # keep the rest as-is
+ ('session', 'messaging', 'verbose'): 3,
+ ('storage', 'library', 'autosave'): 20, # change value
+ ('storage', 'library', 'autoindex'): 20, # add key
+ ('storage', 'library', 'write_through'): True, # keep key
+ ('storage', 'numerical', 'write_through'): False, # add key
+ ('storage', 'index', 'preview_size'): [10, 20, 30], # keep key
+ })
+
+ # test nesting
+ cfg['session']['paths'] = {'library': '/path/to/elsewhere'}
+ self.assertEqual('/path/to/elsewhere', cfg('session', 'paths', 'library'))
+ cfg['session', 'paths'] = {'library': '/path/to/elsewhere2'}
+ self.assertEqual('/path/to/elsewhere2', cfg('session', 'paths', 'library'))
+ cfg['session']['paths']['library'] = '/path/to/elsewhere3'
+ self.assertEqual('/path/to/elsewhere3', cfg('session', 'paths', 'library'))
+
+ def test_set_branch_dict(self):
+ # unknowns
+ cfg = Settings(schema=ConfigSchema(), data={
+ ('session', 'paths', 'preview', 'files'): 'thumbs'
+ })
+ # adding siblings is allowed
+ cfg.set(('session', 'paths', 'preview', 'original'), '')
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'preview', 'files'): 'thumbs',
+ ('session', 'paths', 'preview', 'original'): '',
+ })
+ # clearing sub-items is allowed
+ cfg.set(('session', 'paths', 'preview'), {})
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'preview'): {},
+ })
+ # adding sub-items is allowed
+ cfg.set(('session', 'paths', 'preview', 'sqlite'), 'thumbs.db')
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'preview', 'sqlite'): 'thumbs.db',
+ })
+ # adding siblings dict-style is allowed
+ cfg.set(('session', 'paths', 'preview'), {'exif': 'none', 'rest': {}})
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'preview', 'sqlite'): 'thumbs.db',
+ ('session', 'paths', 'preview', 'exif'): 'none',
+ ('session', 'paths', 'preview', 'rest'): {},
+ })
+ # adding sub-items to non-dict items is not allowed
+ self.assertRaises(TypeError, cfg.set, ('session', 'paths', 'preview', 'exif', 'sub'), 123)
+ # adding super-items of non-dict type is not allowed
+ self.assertRaises(TypeError, cfg.set, ('session', 'paths', 'preview'), 123)
+
+ def test_unset_branch(self):
+ # mixed knowns/unknowns
+ self.cfg.unset('session')
+ self.assertNotIn(('session', 'messaging', 'verbose'), self.cfg)
+ self.assertNotIn(('session', 'messaging'), self.cfg)
+ self.assertIn(('session', ), self.cfg)
+ self.assertEqual('', self.cfg('session', 'paths', 'library'))
+
+ # already empty
+ self.cfg.unset('ui', 'standalone')
+ self.assertEqual(3, self.cfg('ui', 'standalone', 'browser', 'cols'))
+
+ def test_unset_branch_alias(self):
+ # mixed knowns/unknowns
+ del self.cfg['session']
+ self.assertNotIn(('session', 'messaging', 'verbose'), self.cfg)
+ self.assertNotIn(('session', 'messaging'), self.cfg)
+ self.assertIn(('session', ), self.cfg)
+ self.assertEqual('', self.cfg('session', 'paths', 'library'))
+
+ # already empty
+ del self.cfg['ui', 'standalone']
+ self.assertEqual(3, self.cfg('ui', 'standalone', 'browser', 'cols'))
+
+ def test_has(self):
+ # check default
+ self.assertIn(('ui', 'standalone', 'browser', 'cols'), self.cfg)
+ # check configured
+ self.assertIn(('session', 'paths', 'library'), self.cfg)
+ # check newly configured
+ self.cfg.set(('ui', 'standalone', 'browser', 'cols'), 4)
+ self.assertIn(('ui', 'standalone', 'browser', 'cols'), self.cfg)
+ # check invalid
+ self.assertNotIn(('ui', 'standalone', 'browser', 'rows'), self.cfg)
+ # check branch
+ self.assertIn(('ui', 'standalone', 'browser'), self.cfg)
+ # check branch
+ self.assertNotIn(('ui', 'standalone', 'filter'), self.cfg)
+
+ # check unknown
+ self.assertIn(('session', 'messaging', 'verbose'), self.cfg)
+ self.assertNotIn(('session', 'messaging', 'debug'), self.cfg)
+ # check unknown branch
+ self.assertIn(('session', 'messaging'), self.cfg)
+ self.assertNotIn(('storage', 'library'), self.cfg)
+ self.assertNotIn(('storage', ), self.cfg)
+
+ def test_iteration(self):
+ # length
+ self.assertEqual(5, len(self.cfg))
+ # iterate keys, explicit/implicit
+ self.assertCountEqual(list(self.cfg), [
+ ('ui', 'standalone', 'browser', 'cols'),
+ ('extraction', 'constant', 'enabled'),
+ ('session', 'paths', 'library'),
+ ('session', 'paths', 'config'),
+ ('session', 'messaging', 'verbose')])
+ self.assertCountEqual(list(self.cfg.keys()), [
+ ('ui', 'standalone', 'browser', 'cols'),
+ ('extraction', 'constant', 'enabled'),
+ ('session', 'paths', 'library'),
+ ('session', 'paths', 'config'),
+ ('session', 'messaging', 'verbose')])
+ # iterate items
+ self.assertCountEqual(list(self.cfg.items()), [
+ (('ui', 'standalone', 'browser', 'cols'), 3),
+ (('extraction', 'constant', 'enabled'), False),
+ (('session', 'paths', 'library'), '/path/to/lib'),
+ (('session', 'paths', 'config'), ''),
+ (('session', 'messaging', 'verbose'), 8)])
+
+ def test_magicks(self):
+ # comparison
+ self.assertEqual(self.cfg, self.cfg)
+ self.assertEqual(self.cfg, self.cfg())
+ self.assertNotEqual(self.cfg, self.cfg('ui'))
+ self.assertEqual(self.cfg, Settings(self.schema, data=self.cfg.config))
+ self.assertNotEqual(self.cfg, Settings(ConfigSchema(), data=self.cfg.config))
+ self.assertNotEqual(self.cfg, Settings(self.schema))
+ self.assertEqual(hash(self.cfg), hash(self.cfg))
+ self.assertEqual(hash(self.cfg), hash(self.cfg()))
+ self.assertEqual(hash(self.cfg), hash(Settings(self.schema, data=self.cfg.config)))
+ # representation
+ self.assertEqual(str(self.cfg), str(self.cfg.config))
+ self.assertEqual(repr(self.cfg), 'Settings(prefix=(), keys=2, len=5)')
+
+ def test_diff(self):
+ """
+ # diff
+ >>> cfg.unset('ui', 'standalone', 'browser', 'cols')
+ >>> cfg.diff(Settings())
+ Settings({'session': {'paths': {'library': '/path/to/lib'}}})
+ >>> cfg.set(('ui', 'standalone', 'browser', 'cols'), 4)
+ >>> cfg.diff(Settings())
+ Settings({'session': {'paths': {'library': '/path/to/lib'}},
+ 'ui': {'standalone': {'browser': {'cols': 4}}}})
+ """
+ # contains all configured
+ self.assertDictEqual(self.cfg.diff(Settings()).config, {
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'messaging', 'verbose'): 8,
+ })
+ # still no cols (because it won't be stored in self.cfg)
+ self.cfg.set(('ui', 'standalone', 'browser', 'cols'), 3)
+ self.assertDictEqual(self.cfg.diff(Settings()).config, {
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'messaging', 'verbose'): 8,
+ })
+ # still no cols (because it's the default)
+ self.assertDictEqual(self.cfg.diff(Settings(data=
+ {('ui', 'standalone', 'browser', 'cols'): 3}
+ )).config, {
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'messaging', 'verbose'): 8,
+ })
+ # now with cols (because it deviates)
+ self.assertDictEqual(self.cfg.diff(Settings(data=
+ {('ui', 'standalone', 'browser', 'cols'): 5}
+ )).config, {
+ ('ui', 'standalone', 'browser', 'cols'): 3,
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'messaging', 'verbose'): 8,
+ })
+ # non-differing known
+ self.assertDictEqual(self.cfg.diff(Settings(data=
+ {('session', 'paths', 'library'): '/path/to/lib'}
+ )).config, {
+ ('session', 'messaging', 'verbose'): 8,
+ })
+ # non-differing unknown
+ self.assertDictEqual(self.cfg.diff(Settings(data=
+ {('session', 'messaging', 'verbose'): 8}
+ )).config, {
+ ('session', 'paths', 'library'): '/path/to/lib',
+ })
+
+ def test_flatten_tree(self):
+ schema = ConfigSchema()
+ schema.declare(('ui', 'standalone', 'tiledocks'),
+ types.Dict(types.String(), types.Dict(types.String(), types.Int())), {})
+ schema.declare(('session', 'debug'), types.Bool(), True)
+ schema.declare(('storage', 'library', 'autosave'), types.Unsigned(), 0)
+
+ # ordinary scenario
+ flat = Settings.flatten_tree({
+ 'session': {
+ 'debug': True,
+ 'paths': {
+ 'library': '/path/to/lib',
+ 'config': {'path': '/path/to/conf' },
+ }
+ }}, schema, on_error='raise')
+ self.assertDictEqual(flat, {
+ ('session', 'debug'): True, # defaults are kept
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'paths', 'config', 'path'): '/path/to/conf',
+ })
+
+ # dict types
+ flat = Settings.flatten_tree({
+ 'ui': {
+ 'standalone': {
+ 'tiledocks': {
+ 'dashboard': {
+ 'Buttons': 123
+ }}}}}, schema, on_error='raise')
+ self.assertDictEqual(flat, {
+ ('ui', 'standalone', 'tiledocks'): {'dashboard': {'Buttons': 123}}})
+
+ flat = Settings.flatten_tree({
+ 'ui': {
+ 'standalone': {
+ 'tiledocks': {
+ 'dashboard': {}
+ }}}}, schema)
+ self.assertDictEqual(flat, {
+ ('ui', 'standalone', 'tiledocks'): {'dashboard': {}}
+ })
+
+ # dict types w/o schema
+ flat = Settings.flatten_tree({
+ 'ui': {
+ 'standalone': {
+ 'tiledocks': {
+ 'dashboard': {
+ 'Buttons': 123
+ }}}}}, ConfigSchema(), on_error='raise')
+ self.assertDictEqual(flat, {
+ ('ui', 'standalone', 'tiledocks', 'dashboard', 'Buttons'): 123})
+
+ # dict types w/o schema
+ flat = Settings.flatten_tree({
+ 'ui': {
+ 'standalone': {
+ 'tiledocks': {
+ 'dashboard': {
+ 'Buttons': {}
+ }}}}}, ConfigSchema(), on_error='raise')
+ self.assertDictEqual(flat, {
+ ('ui', 'standalone', 'tiledocks', 'dashboard', 'Buttons'): {}})
+
+ # error case: invalid value
+ config = {
+ 'session': {
+ 'debug': 123,
+ 'paths': {
+ 'library': '/path/to/lib',
+ 'config': {'path': '/path/to/conf' },
+ }
+ }}
+ self.assertRaises(TypeError, Settings.flatten_tree, config, schema, on_error='raise')
+ flat = Settings.flatten_tree(config, schema, on_error='ignore')
+ self.assertDictEqual(flat, {
+ # debug is omitted because it's invalid
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'paths', 'config', 'path'): '/path/to/conf',
+ })
+ # error case: invalid subkey
+ config = {
+ 'session': {
+ 'debug': {
+ 'verbose': True
+ },
+ 'paths': {
+ 'library': '/path/to/lib',
+ 'config': {'path': '/path/to/conf' },
+ }
+ }}
+ self.assertRaises(TypeError, Settings.flatten_tree, config, schema, on_error='raise')
+ flat = Settings.flatten_tree(config, schema, on_error='ignore')
+ self.assertDictEqual(flat, {
+ # debug is omitted because it's invalid
+ ('session', 'paths', 'library'): '/path/to/lib',
+ ('session', 'paths', 'config', 'path'): '/path/to/conf',
+ })
+ # error case: invalid superkey
+ config = {
+ 'session': 123,
+ 'storage': {
+ 'library': {
+ 'autosave': -4,
+ 'write_through': True
+ }}}
+ self.assertRaises(TypeError, Settings.flatten_tree, config, schema, on_error='raise')
+ flat = Settings.flatten_tree(config, schema, on_error='ignore')
+ self.assertDictEqual(flat, {('storage', 'library', 'write_through'): True})
+
+ def test_clone(self):
+ cfg = self.cfg.clone()
+ self.assertEqual(self.cfg, cfg)
+ cfg.set(('session', 'paths', 'library'), '/path/to/elsewhere')
+ self.assertEqual('/path/to/elsewhere', cfg('session', 'paths', 'library'))
+ self.assertEqual('/path/to/lib', self.cfg('session', 'paths', 'library'))
+ self.assertNotEqual(self.cfg, cfg)
+
+ def test_file_connected(self):
+ self.assertFalse(self.cfg.file_connected())
+ self.cfg.set(('session', 'paths', 'config'), '/path/to/somewhere')
+ self.assertTrue(self.cfg.file_connected())
+ self.cfg.unset('session', 'paths', 'config')
+ self.assertFalse(self.cfg.file_connected())
+
+ def test_schema_changes(self):
+ schema = ConfigSchema()
+ cfg = Settings.Open({
+ 'ui': {'standalone': {'tiledocks': {'Buttons': {'buttons': [1,2,3]},
+ 'Info': {},
+ }}},
+ 'session': {'paths': {'preview': {'files': 'thumbs'}},
+ 'debug': False,
+ 'size': 'will_become_invalid',
+ },
+ }, schema=schema)
+ self.assertDictEqual(cfg.config, {
+ ('ui', 'standalone', 'tiledocks', 'Buttons', 'buttons'): [1,2,3],
+ ('ui', 'standalone', 'tiledocks', 'Info'): {},
+ ('session', 'paths', 'preview', 'files'): 'thumbs',
+ ('session', 'debug'): False,
+ ('session', 'size'): 'will_become_invalid',
+ })
+
+ schema.declare(('ui', 'standalone', 'tiledocks'),
+ types.Dict(types.String(), types.Dict(types.String(), types.Any())), {})
+ schema.declare(('session', 'paths', 'preview'),
+ types.Dict(types.String(), types.String()), {})
+ schema.declare(('session', 'debug'), types.Bool(), False)
+
+ cfg.rebase(clear_defaults=False)
+ self.assertDictEqual(cfg.config, {
+ ('ui', 'standalone', 'tiledocks'): {'Buttons': {'buttons': [1,2,3]}, 'Info': {}},
+ ('session', 'paths', 'preview'): {'files': 'thumbs'},
+ ('session', 'debug'): False, # is still in here
+ ('session', 'size'): 'will_become_invalid',
+ })
+
+ cfg.rebase()
+ self.assertDictEqual(cfg.config, {
+ ('ui', 'standalone', 'tiledocks'): {'Buttons': {'buttons': [1,2,3]}, 'Info': {}},
+ ('session', 'paths', 'preview'): {'files': 'thumbs'},
+ # now session.debug is gone
+ ('session', 'size'): 'will_become_invalid',
+ })
+
+ # create a schema violation
+ schema.declare(('session', 'size'), types.Int(), 0)
+ # raises an error
+ self.assertRaises(TypeError, cfg.rebase)
+ # ignore the error, size will be removed
+ cfg.rebase(on_error='ignore')
+ self.assertDictEqual(cfg.config, {
+ ('ui', 'standalone', 'tiledocks'): {'Buttons': {'buttons': [1,2,3]}, 'Info': {}},
+ ('session', 'paths', 'preview'): {'files': 'thumbs'},
+ })
+
+ def test_to_tree(self):
+ self.assertDictEqual(self.cfg.to_tree(), {
+ 'session': {'paths': {'library': '/path/to/lib'},
+ 'messaging': {'verbose': 8}},
+ })
+ self.assertDictEqual(self.cfg.to_tree(defaults=True), {
+ 'session': {'paths': {'library': '/path/to/lib',
+ 'config': ''},
+ 'messaging': {'verbose': 8}},
+ 'ui': {'standalone': {'browser': {'cols': 3}}},
+ 'extraction': {'constant': {'enabled': False}}
+ })
+ # corner cases
+ self.assertDictEqual(Settings(schema=self.schema, data={}).to_tree(), {})
+ self.assertDictEqual(Settings(schema=self.schema, data={('session', ): True}).to_tree(),
+ {'session': True})
+ # tree from subkey
+ self.assertDictEqual(self.cfg('session', 'paths').to_tree(), {
+ 'library': '/path/to/lib'})
+ self.assertDictEqual(self.cfg('session', 'paths').to_tree(defaults=True), {
+ 'library': '/path/to/lib',
+ 'config': ''})
+
+ def test_Open_formats(self):
+ config = {
+ 'session': {
+ 'paths': {
+ 'library': '/path/to/somewhere',
+ 'numerical': '/path/to/numerical',
+ },
+ 'messaging': {
+ 'debug': True,
+ 'verbose': False,
+ },
+ },
+ 'extraction': {
+ 'constant': {
+ 'enabled': False
+ },
+ }}
+
+ # store to file
+ path = tempfile.mkstemp(prefix='tagit_')[1]
+ with open(path, 'w') as ofile:
+ json.dump(config, ofile)
+
+ # load from json string
+ cfg = Settings.Open(json.dumps(config), schema=self.schema)
+ # default
+ self.assertEqual(3, cfg('ui', 'standalone', 'browser', 'cols'))
+ self.assertEqual(False, cfg('extraction', 'constant', 'enabled'))
+ # invalid
+ self.assertNotIn(('ui', 'standalone', 'browser', 'rows'), cfg)
+ # known
+ self.assertEqual('/path/to/somewhere', cfg('session', 'paths', 'library'))
+ # unknown
+ self.assertEqual('/path/to/numerical', cfg('session', 'paths', 'numerical'))
+ self.assertTrue(cfg('session', 'messaging', 'debug'))
+ self.assertFalse(cfg('session', 'messaging', 'verbose'))
+ # config dict
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'library'): '/path/to/somewhere',
+ ('session', 'paths', 'numerical'): '/path/to/numerical',
+ ('session', 'messaging', 'debug'): True,
+ ('session', 'messaging', 'verbose'): False,
+ })
+
+ # load from dict
+ cfg2 = Settings.Open(config, schema=self.schema)
+ self.assertDictEqual(cfg.config, cfg2.config)
+
+ # load from file
+ cfg2 = Settings.Open(path, schema=self.schema)
+ self.assertDictEqual(cfg.config, cfg2.config)
+
+ # load from opened file
+ with open(path) as ifile:
+ cfg2 = Settings.Open(ifile, schema=self.schema)
+ self.assertDictEqual(cfg.config, cfg2.config)
+
+ # invalid type
+ self.assertRaises(TypeError, Settings.Open, 15, schema=self.schema)
+
+ os.unlink(path)
+
+ def test_Open_errors(self):
+ # invalid value
+ config = {'session': {'paths': {'library': 15}}}
+ self.assertRaises(types.ConfigTypeError, Settings.Open, config, schema=self.schema)
+ # too deep
+ config = {'session': {'paths': {'library': {'plain': '/path/to/somewhere'}}}}
+ self.assertRaises(types.ConfigTypeError, Settings.Open, config, schema=self.schema)
+ # too shallow
+ config = {'session': {'paths': 123}}
+ self.assertRaises(ConfigError, Settings.Open, config, schema=self.schema)
+ # empty
+ cfg = Settings.Open({}, schema=self.schema)
+ self.assertDictEqual(cfg.config, {})
+
+ def test_Open_dict_keys(self):
+ schema = ConfigSchema()
+ schema.declare(('ui', 'standalone', 'tiledocks'),
+ types.Dict(types.String(), types.Dict(types.String(), types.Any())), {})
+ # correct settings
+ config = {
+ 'ui': {
+ 'standalone': {
+ 'tiledocks': {
+ 'dashboard': {
+ 'Buttons': {
+ 'buttons': ['ShowBrowsing', 'ShowDashboard']
+ },
+ 'Info': {},
+ },
+ 'sidebar': {
+ 'LibSummary': {'size': 200},
+ },
+ }}}}
+ cfg = Settings.Open(config, schema=schema)
+ self.assertDictEqual(cfg('ui', 'standalone', 'tiledocks'),
+ config['ui']['standalone']['tiledocks'])
+ self.assertNotIn(('ui', 'standalone', 'tiledocks', 'dashboard'), cfg)
+
+ # incorrect settings
+ config = {
+ 'ui': {
+ 'standalone': {
+ 'tiledocks': {
+ 'dashboard': {
+ 'Buttons': {
+ 'buttons': ['ShowBrowsing', 'ShowDashboard']
+ },
+ 'Info': {},
+ },
+ 'sidebar': [ 'LibSummary' ],
+ }}}}
+ self.assertRaises(types.ConfigTypeError, Settings.Open, config, schema=schema)
+
+ def test_Open_sequence(self):
+ # start with empty config
+ schema = ConfigSchema()
+ schema.declare(('session', 'paths', 'library'), types.Path(), '')
+ schema.declare(('session', 'verbose'), types.Int(), 0)
+ schema.declare(('session', 'debug'), types.Bool(), False)
+
+ cfg = Settings(schema=schema)
+ # update from first source
+ cfg.update(Settings.Open({
+ 'session': {'verbose': 1}
+ }, schema=schema, clear_defaults=False))
+ # update from second source
+ cfg.update(Settings.Open({
+ 'session': {'paths': {'library': 'foobar'},
+ 'debug': True}
+ }, schema=schema, clear_defaults=False))
+ # update from third source
+ cfg.update(Settings.Open({
+ 'session': {'paths': {'library': 'barfoo'},
+ 'debug': False}
+ }, schema=schema, clear_defaults=False))
+ # check: the default value was cleared
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'library'): 'barfoo',
+ ('session', 'verbose'): 1,
+ })
+ # update from fourth source
+ cfg.update(Settings.Open({
+ 'session': {'paths': {'library': '/path/to/lib'},
+ 'verbose': 0}
+ }, schema=schema, clear_defaults=False))
+ # check: the default value is again cleared
+ self.assertDictEqual(cfg.config, {
+ ('session', 'paths', 'library'): '/path/to/lib',
+ })
+
+ def test_update(self):
+ # plain test
+ self.cfg.update(Settings(schema=self.schema, data={
+ ('session', 'paths', 'library'): '/path/to/elsewhere',
+ ('session', 'paths', 'config'): '/path/to/somewhere',
+ ('ui', 'standalone', 'browser', 'cols'): 3,
+ ('ui', 'standalone', 'browser', 'rows'): 5,
+ }))
+ self.assertDictEqual(self.cfg.config, {
+ ('session', 'paths', 'library'): '/path/to/elsewhere', # overwrite knowns
+ ('session', 'paths', 'config'): '/path/to/somewhere', # add new knowns
+ # don't add defaults
+ ('ui', 'standalone', 'browser', 'rows'): 5, # add new unknowns
+ ('session', 'messaging', 'verbose'): 8, # keep old values
+ })
+ # reset to default
+ self.cfg.update(Settings(schema=self.schema, data={
+ ('session', 'paths', 'library'): '',
+ ('session', 'messaging', 'verbose'): 5
+ }))
+ self.assertDictEqual(self.cfg.config, {
+ # removed due to becoming default
+ ('session', 'paths', 'config'): '/path/to/somewhere',
+ ('ui', 'standalone', 'browser', 'rows'): 5,
+ ('session', 'messaging', 'verbose'): 5, # overwrite unknowns
+ })
+
+ # error: invalid value
+ self.assertRaises(TypeError, self.cfg.update,
+ Settings(schema=self.schema, data={
+ ('session', 'paths', 'library'): 15,
+ ('ui', 'standalone', 'browser', 'rows'): 5,
+ }))
+
+ # error: known superkey
+ self.assertRaises(TypeError, self.cfg.update,
+ Settings(schema=self.schema, data={
+ ('session', 'paths'): 15,
+ }))
+ # error: known subkey
+ self.assertRaises(TypeError, self.cfg.update,
+ Settings(schema=self.schema, data={
+ ('session', 'paths', 'config', 'write_through'): False,
+ }))
+
+ # error: unknown superkey
+ self.assertRaises(TypeError, self.cfg.update,
+ Settings(schema=self.schema, data={
+ ('session', 'messaging'): 15,
+ ('ui', 'standalone', 'browser', 'rows'): 5,
+ }))
+ # error: unknown subkey
+ self.assertRaises(TypeError, self.cfg.update,
+ Settings(schema=self.schema, data={
+ ('session', 'messaging', 'verbose', 'debug'): True,
+ ('ui', 'standalone', 'browser', 'rows'): 5,
+ }))
+
+ # error: unconfigured known superkey
+ self.assertRaises(TypeError, Settings(schema=self.schema, data={
+ }).update, Settings(schema=ConfigSchema(), data={
+ ('ui', 'standalone', 'browser'): 15
+ }))
+ # error: unconfigured known subkey
+ self.assertRaises(TypeError, Settings(schema=self.schema, data={
+ }).update, Settings(schema=ConfigSchema(), data={
+ ('ui', 'standalone', 'browser', 'cols', 'foo'): 15
+ }))
+
+ # corner case: overwrite unknown subkey with dict
+ cfg = Settings(schema=ConfigSchema(), data={
+ ('view', 'toolbag', 'right', 'Info'): 3
+ }).update(Settings(schema=ConfigSchema(), data={
+ ('view', 'toolbag', 'right'): {}
+ }))
+ self.assertDictEqual(cfg.config, {
+ ('view', 'toolbag', 'right'): {}
+ })
+
+ def test_save(self):
+ # needs uri
+ self.assertRaises(ValueError, self.cfg.save)
+ # can save if uri is known
+ path = tempfile.mkstemp(prefix='tagit_')[1]
+ self.assertEqual(os.stat(path).st_size, 0)
+ self.cfg.save(path)
+ # file has changed
+ self.assertGreater(os.stat(path).st_size, 0)
+ # can restore from file
+ cfg = Settings.Open(path, schema=self.schema)
+ self.assertEqual(cfg, self.cfg)
+ self.assertDictEqual(cfg.config, self.cfg.config)
+ # can save to buffer
+ buf = io.StringIO()
+ self.cfg.save(buf)
+ with open(path) as ifile:
+ self.assertEqual(buf.getvalue(), ifile.read())
+ os.unlink(path)
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##
diff --git a/test/config/test_types.py b/test/config/test_types.py
new file mode 100644
index 0000000..31537e6
--- /dev/null
+++ b/test/config/test_types.py
@@ -0,0 +1,251 @@
+"""
+
+Part of the tagit test suite.
+A copy of the license is provided with the project.
+Author: Matthias Baumgartner, 2022
+"""
+# imports
+import unittest
+
+# tagit imports
+from tagit.config.types import ConfigTypeError
+
+# objects to test
+from tagit.config import types
+
+
+## code ##
+
+class TestTypes(unittest.TestCase):
+ def test_any(self):
+ inst = types.Any()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Any')
+ self.assertEqual(repr(inst), 'Any()')
+ # comparison (eq, hash)
+ self.assertEqual(types.Any(), types.Any())
+ self.assertEqual(hash(types.Any()), hash(types.Any()))
+ # backtrack
+ self.assertIsNone(inst.backtrack(None, ''))
+ self.assertIsNone(inst.backtrack('foobar', ''))
+ self.assertIsNone(inst.backtrack(123, ''))
+ self.assertIsNone(inst.backtrack([], ''))
+ self.assertIsNone(inst.backtrack(inst, ''))
+
+ def test_bool(self):
+ inst = types.Bool()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Bool')
+ self.assertEqual(repr(inst), 'Bool()')
+ # comparison (eq, hash)
+ self.assertEqual(types.Bool(), types.Bool())
+ self.assertEqual(hash(types.Bool()), hash(types.Bool()))
+ # backtrack
+ self.assertIsNone(inst.backtrack(True, ''))
+ self.assertIsNone(inst.backtrack(False, ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 123, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foo', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+
+ def test_keybind(self):
+ inst = types.Keybind()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Keybind')
+ self.assertEqual(repr(inst), 'Keybind()')
+ # comparison (eq, hash)
+ self.assertEqual(types.Keybind(), types.Keybind())
+ self.assertEqual(hash(types.Keybind()), hash(types.Keybind()))
+ # backtrack
+ self.assertIsNone(inst.backtrack([], ''))
+ self.assertIsNone(inst.backtrack([(5, ('ctrl', ), ('all', ))], ''))
+ self.assertIsNone(inst.backtrack([('abc', ('rest', ), ('all', ))], ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 123, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foo', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [1,2,3], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [(1,2,3)], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [(1,(3,),3)], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [(1,3,(3,))], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [(3,(3,),('all',))], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [(3,('foobar',),('all',))], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [(3,('all',),(3,))], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [(3,('all',),('foobar',))], '')
+
+ def test_int(self):
+ inst = types.Int()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Int')
+ self.assertEqual(repr(inst), 'Int()')
+ # comparison (eq, hash)
+ self.assertEqual(types.Int(), types.Int())
+ self.assertEqual(hash(types.Int()), hash(types.Int()))
+ # backtrack
+ self.assertIsNone(inst.backtrack(0, ''))
+ self.assertIsNone(inst.backtrack(1, ''))
+ self.assertIsNone(inst.backtrack(-1, ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 1.23, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foo', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+
+ def test_unsigned(self):
+ inst = types.Unsigned()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Unsigned int')
+ self.assertEqual(repr(inst), 'Unsigned()')
+ # comparison (eq, hash)
+ self.assertEqual(types.Unsigned(), types.Unsigned())
+ self.assertEqual(hash(types.Unsigned()), hash(types.Unsigned()))
+ # backtrack
+ self.assertIsNone(inst.backtrack(0, ''))
+ self.assertIsNone(inst.backtrack(1, ''))
+ self.assertIsNone(inst.backtrack(123, ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, -1, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 1.23, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foo', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+
+ def test_float(self):
+ inst = types.Float()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Float')
+ self.assertEqual(repr(inst), 'Float()')
+ # comparison (eq, hash)
+ self.assertEqual(types.Float(), types.Float())
+ self.assertEqual(hash(types.Float()), hash(types.Float()))
+ # backtrack
+ self.assertIsNone(inst.backtrack(1.23, ''))
+ self.assertIsNone(inst.backtrack(-1.23, ''))
+ self.assertIsNone(inst.backtrack(1, ''))
+ self.assertIsNone(inst.backtrack(-1, ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foo', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+
+ def test_string(self):
+ inst = types.String()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'String')
+ self.assertEqual(repr(inst), 'String()')
+ # comparison (eq, hash)
+ self.assertEqual(types.String(), types.String())
+ self.assertEqual(hash(types.String()), hash(types.String()))
+ # backtrack
+ self.assertIsNone(inst.backtrack('', ''))
+ self.assertIsNone(inst.backtrack('foobar', ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 123, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 1.23, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+
+ def test_path(self):
+ inst = types.Path()
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Path')
+ self.assertEqual(repr(inst), 'Path()')
+ # comparison (eq, hash)
+ self.assertEqual(types.Path(), types.Path())
+ self.assertEqual(hash(types.Path()), hash(types.Path()))
+ # backtrack
+ self.assertIsNone(inst.backtrack('', ''))
+ self.assertIsNone(inst.backtrack('foobar', ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 123, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 1.23, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+
+ def test_enum(self):
+ inst = types.Enum('foo', 'bar', 123)
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'One out of ({})'.format(
+ ', '.join(str(itm) for itm in inst.options)))
+ self.assertEqual(repr(inst), f'Enum([{inst.options}])')
+ # comparison (eq, hash)
+ self.assertEqual(types.Enum(), types.Enum())
+ self.assertEqual(types.Enum('foo', 'bar'), types.Enum(['foo', 'bar']))
+ self.assertEqual(types.Enum(123, 'bar'), types.Enum(['bar', 123]))
+ self.assertEqual(hash(types.Enum('foo')), hash(types.Enum(['foo'])))
+ # backtrack
+ self.assertIsNone(inst.backtrack('foo', ''))
+ self.assertIsNone(inst.backtrack('bar', ''))
+ self.assertIsNone(inst.backtrack(123, ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foobar', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 1.23, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, inst, '')
+
+ def test_list(self):
+ inst = types.List(types.Int())
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'List of Int')
+ self.assertEqual(repr(inst), 'List(Int)')
+ # comparison (eq, hash)
+ self.assertEqual(types.List(types.Int()), types.List(types.Int()))
+ self.assertNotEqual(types.List(types.Int()), types.List(types.Unsigned()))
+ self.assertNotEqual(types.List(types.Unsigned()), types.List(types.Int()))
+ self.assertNotEqual(types.List(types.Unsigned()), types.List(types.String()))
+ self.assertNotEqual(types.List(types.Bool()), types.List(types.String()))
+ self.assertEqual(hash(types.List(types.Int())), hash(types.List(types.Int())))
+ self.assertEqual(hash(types.List(types.Unsigned())), hash(types.List(types.Unsigned())))
+ self.assertEqual(hash(types.List(types.String())), hash(types.List(types.String())))
+ # backtrack
+ self.assertIsNone(inst.backtrack([], ''))
+ self.assertIsNone(inst.backtrack([1,2,3], ''))
+ self.assertIsNone(inst.backtrack((1,2,3), ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, {1,2,3}, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foobar', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 1.23, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, ['a', 'b', 'c'], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [123, 'b', 'c'], '')
+
+ def test_dict(self):
+ inst = types.Dict(types.String(), types.Int())
+ # representation (str, repr)
+ self.assertEqual(str(inst), 'Dict from String to Int')
+ self.assertEqual(repr(inst), 'Dict(String, Int)')
+ # comparison (eq, hash)
+ self.assertEqual(types.Dict(types.Int(), types.String()),
+ types.Dict(types.Int(), types.String()))
+ self.assertEqual(types.Dict(types.List(types.Int()), types.String()),
+ types.Dict(types.List(types.Int()), types.String()))
+ self.assertNotEqual(types.Dict(types.Int(), types.Unsigned()),
+ types.Dict(types.Unsigned(), types.Int()))
+ self.assertNotEqual(types.Dict(types.Unsigned(), types.String()),
+ types.Dict(types.Int(), types.String()))
+ self.assertNotEqual(types.Dict(types.Unsigned(), types.String()),
+ types.Dict(types.Int(), types.String()))
+ self.assertEqual(hash(types.Dict(types.Int(), types.String())),
+ hash(types.Dict(types.Int(), types.String())))
+ self.assertEqual(hash(types.Dict(types.List(types.Int()), types.String())),
+ hash(types.Dict(types.List(types.Int()), types.String())))
+ # backtrack
+ self.assertIsNone(inst.backtrack({'foo': 2}, ''))
+ self.assertIsNone(inst.backtrack({'foo': 2, 'bar': 3}, ''))
+ self.assertIsNone(inst.backtrack({}, ''))
+ self.assertRaises(ConfigTypeError, inst.backtrack, None, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, {1,2,3}, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 'foobar', '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, 1.23, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, ['a', 'b', 'c'], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, [123, 'b', 'c'], '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, {2: 'foo', 3: 'bar'}, '')
+ self.assertRaises(ConfigTypeError, inst.backtrack, {'foo': 2, 3: 'bar'}, '')
+
+
+## main ##
+
+if __name__ == '__main__':
+ unittest.main()
+
+## EOF ##