diff options
author | Matthias Baumgartner <dev@igsor.net> | 2023-01-06 12:20:22 +0100 |
---|---|---|
committer | Matthias Baumgartner <dev@igsor.net> | 2023-01-06 12:20:22 +0100 |
commit | 079b4da93ea336b5bcc801cfd64c310aa7f8ddee (patch) | |
tree | 9c9a1cf7cbb9d71ba8dcce395996a1af3db790e2 | |
parent | 0ba7a15c124d3a738a45247e78381dd56f7f1fa9 (diff) | |
download | tagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.tar.gz tagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.tar.bz2 tagit-079b4da93ea336b5bcc801cfd64c310aa7f8ddee.zip |
config early port (test still fails)
-rw-r--r-- | tagit/config/__init__.py | 25 | ||||
-rw-r--r-- | tagit/config/loader.py | 83 | ||||
-rw-r--r-- | tagit/config/schema.py | 283 | ||||
-rw-r--r-- | tagit/config/settings.json | 70 | ||||
-rw-r--r-- | tagit/config/settings.py | 473 | ||||
-rw-r--r-- | tagit/config/types.py | 273 | ||||
-rw-r--r-- | tagit/config/user-defaults.json | 142 | ||||
-rw-r--r-- | tagit/config/utils.py | 104 | ||||
-rw-r--r-- | tagit/utils/__init__.py | 1 | ||||
-rw-r--r-- | tagit/utils/errors.py | 52 | ||||
-rw-r--r-- | tagit/utils/shared.py | 63 | ||||
-rw-r--r-- | test/__init__.py | 0 | ||||
-rw-r--r-- | test/config/__init__.py | 0 | ||||
-rw-r--r-- | test/config/test_schema.py | 284 | ||||
-rw-r--r-- | test/config/test_settings.py | 903 | ||||
-rw-r--r-- | test/config/test_types.py | 251 |
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 ## |