From 72d33c62a91e5f0f3bbcad103f1f62e56522ea5a Mon Sep 17 00:00:00 2001 From: Michael Hanke Date: Thu, 26 Sep 2024 22:20:47 +0200 Subject: [PATCH] feat: hierachical, multi-source settings manager DRAFT --- datasalad/__init__.py | 2 + datasalad/settings/__init__.py | 53 ++++++ datasalad/settings/defaults.py | 31 ++++ datasalad/settings/env.py | 143 ++++++++++++++++ datasalad/settings/setting.py | 124 ++++++++++++++ datasalad/settings/settings.py | 107 ++++++++++++ datasalad/settings/source.py | 195 ++++++++++++++++++++++ datasalad/settings/tests/__init__.py | 0 datasalad/settings/tests/test_defaults.py | 53 ++++++ datasalad/settings/tests/test_env.py | 112 +++++++++++++ datasalad/settings/tests/test_setting.py | 34 ++++ datasalad/settings/tests/test_settings.py | 66 ++++++++ datasalad/settings/tests/test_source.py | 68 ++++++++ docs/index.rst | 1 + 14 files changed, 989 insertions(+) create mode 100644 datasalad/settings/__init__.py create mode 100644 datasalad/settings/defaults.py create mode 100644 datasalad/settings/env.py create mode 100644 datasalad/settings/setting.py create mode 100644 datasalad/settings/settings.py create mode 100644 datasalad/settings/source.py create mode 100644 datasalad/settings/tests/__init__.py create mode 100644 datasalad/settings/tests/test_defaults.py create mode 100644 datasalad/settings/tests/test_env.py create mode 100644 datasalad/settings/tests/test_setting.py create mode 100644 datasalad/settings/tests/test_settings.py create mode 100644 datasalad/settings/tests/test_source.py diff --git a/datasalad/__init__.py b/datasalad/__init__.py index 712b32e..1ef7381 100644 --- a/datasalad/__init__.py +++ b/datasalad/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datasalad._version import __version__ __all__ = [ diff --git a/datasalad/settings/__init__.py b/datasalad/settings/__init__.py new file mode 100644 index 0000000..0bf9f7f --- /dev/null +++ b/datasalad/settings/__init__.py @@ -0,0 +1,53 @@ +"""Hierarchical, multi-source settings management + +Validation of configuration item values + + +There are two ways to do validation and type conversion. on-access, or +on-load. Doing it on-load would allow to reject invalid configuration +immediately. But it might spend time on items that never get accessed. +On-access might waste cycles on repeated checks, and possible complain later +than useful. Here we nevertheless run a validator on-access in the default +implementation. Particular sources may want to override this, or ensure that +the stored value that is passed to a validator is already in the best possible +form to make re-validation the cheapest. + +.. currentmodule:: datasalad.settings +.. autosummary:: + :toctree: generated + + Settings + Setting + Source + CachingSource + Environment + InMemorySettings + Defaults + UnsetValue +""" + +from __future__ import annotations + +from .defaults import Defaults +from .env import Environment +from .setting import ( + Setting, + UnsetValue, +) +from .settings import Settings +from .source import ( + CachingSource, + InMemorySettings, + Source, +) + +__all__ = [ + 'CachingSource', + 'Defaults', + 'Environment', + 'InMemorySettings', + 'Setting', + 'Settings', + 'Source', + 'UnsetValue', +] diff --git a/datasalad/settings/defaults.py b/datasalad/settings/defaults.py new file mode 100644 index 0000000..21ec2bc --- /dev/null +++ b/datasalad/settings/defaults.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from datasalad.settings.source import InMemorySettings + +if TYPE_CHECKING: + from datasalad.settings.setting import Setting + +lgr = logging.getLogger('datasalad.settings') + + +class Defaults(InMemorySettings): + """ + Defaults are not loaded from any source. Clients have to set any + items they want to see a default be known for. There would typically be + only one instance of this class, and it is then the true source of the + information by itself. + """ + + def __setitem__(self, key: str, value: Setting) -> None: + if key in self: + # resetting is something that is an unusual event. + # __setitem__ does not allow for a dedicated "force" flag, + # so we leave a message at least + lgr.debug('Resetting %r default', key) + super().__setitem__(key, value) + + def __str__(self): + return 'Defaults' diff --git a/datasalad/settings/env.py b/datasalad/settings/env.py new file mode 100644 index 0000000..5808c4f --- /dev/null +++ b/datasalad/settings/env.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import logging +from os import ( + environ, +) +from os import ( + name as os_name, +) +from typing import ( + TYPE_CHECKING, + Any, +) + +from datasalad.settings.setting import Setting +from datasalad.settings.source import Source + +if TYPE_CHECKING: + from collections.abc import Collection + +lgr = logging.getLogger('datasalad.settings') + + +class Environment(Source): + """Process environment settings source + + This is a stateless source implementation that gets and sets items directly + in the process environment. + + Environment variables can be filtered by declaring a name prefix. More + complex filter rules can be implemented by replacing the + :meth:`include_var()` method in a subclass. + + It is possible to transform environment variable name to setting keys (and + vice versa), by implementing the methods :meth:`get_key_from_varname()` and + :meth:`get_varname_from_key()`. + """ + + is_writable = True + + def __init__( + self, + *, + var_prefix: str | None = None, + ): + super().__init__() + self._var_prefix = var_prefix + + def reinit(self): + """Does nothing""" + + def load(self) -> None: + """Does nothing""" + + def __getitem__(self, key: str) -> Setting: + matching = { + k: v + for k, v in environ.items() + # search for any var that match the key when transformed + if self.include_var(name=k, value=v) and self.get_key_from_varname(k) == key + } + if not matching: + raise KeyError + if len(matching) > 1: + lgr.warning( + 'Ambiguous key %r matching multiple ENV vars: %r', + key, + list(matching.keys()), + ) + k, v = matching.popitem() + return Setting(value=v) + + def __setitem__(self, key: str, value: Setting) -> None: + name = self.get_varname_from_key(key) + environ[name] = str(value.value) + + def get(self, key, default: Any = None) -> Setting: + try: + return self[key] + except KeyError: + if isinstance(default, Setting): + return default + return Setting(value=default) + + def keys(self) -> Collection: + """Returns all keys that can be determined from the environment""" + return { + self.get_key_from_varname(k) + for k, v in environ.items() + if self.include_var(name=k, value=v) + } + + def __str__(self): + return f'Environment[{self._var_prefix}]' if self._var_prefix else 'Environment' + + def __contains__(self, key: str) -> bool: + # we only need to reimplement this due to Python's behavior to + # forece-modify environment variable names on Windows. Only + # talking directly for environ accounts for that + return self.get_varname_from_key(key) in environ + + def __repr__(self): + # TODO: list keys? + return 'Environment()' + + def include_var( + self, + name: str, + value: str, # noqa: ARG002 (default implementation does not need it) + ) -> bool: + """Determine whether to source a setting from an environment variable + + This default implementation tests whether the name of the variable + starts with the ``var_prefix`` given to the constructor. + + Reimplement this method to perform custom tests. + """ + return name.startswith(self._var_prefix or '') + + def get_key_from_varname(self, name: str) -> str: + """Transform an environment variable name to a setting key + + This default implementation performs returns the unchanged + name as a key. + + Reimplement this method and ``get_varname_from_key()`` to perform + custom transformations. + """ + return name + + def get_varname_from_key(self, key: str) -> str: + """Transform a setting key to an environment variable name + + This default implementation on checks for illegal names and + raises a ``ValueError``. Otherwise it returns the unchanged key. + """ + if '=' in key or '\0' in key: + msg = "illegal environment variable name (contains '=' or NUL)" + raise ValueError(msg) + if os_name in ('os2', 'nt'): + # https://stackoverflow.com/questions/19023238/why-python-uppercases-all-environment-variables-in-windows + return key.upper() + return key diff --git a/datasalad/settings/setting.py b/datasalad/settings/setting.py new file mode 100644 index 0000000..f0c050e --- /dev/null +++ b/datasalad/settings/setting.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from copy import copy +from typing import ( + Any, + Callable, +) + + +class UnsetValue: + """Placeholder type to indicate a value that has not been set""" + + +class Setting: + """Representation of an individual setting""" + + def __init__( + self, + value: Any | UnsetValue = UnsetValue, + *, + coercer: Callable | None = None, + lazy: bool = False, + ): + """ + ``value`` can be of any type. A setting instance created with + default :class:`UnsetValue` represents a setting with no known value. + + The ``coercer`` is a callable that processes a setting value + on access via :attr:`value`. This callable can perform arbitrary + processing, including type conversion and validation. + + If ``lazy`` is ``True``, ``value`` must be a callable that requires + no parameters. This callable will be executed each time :attr:`value` + is accessed, and its return value is passed to the ``coercer``. + """ + if lazy and not callable(value): + msg = 'callable required for lazy evaluation' + raise ValueError(msg) + self._value = value + self._coercer = coercer + self._lazy = lazy + + @property + def pristine_value(self) -> Any: + """Original, uncoerced value""" + return self._value + + @property + def value(self) -> Any: + """Value of a setting after coercion + + For a lazy setting, accessing this property also triggers the + evaluation. + """ + # we ignore the type error here + # "error: "UnsetValue" not callable" + # because we rule this out in the constructor + val = self._value() if self._lazy else self._value # type: ignore [operator] + if self._coercer: + return self._coercer(val) + return val + + @property + def coercer(self) -> Callable | None: + """``coercer`` of a setting, or ``None`` if there is none""" + return self._coercer + + @property + def is_lazy(self) -> bool: + """Flag whether the setting evaluates on access""" + return self._lazy + + def update(self, other: Setting) -> None: + """Update the item from another + + This replaces any ``value`` or ``coercer`` set in the other + setting. If case the other's ``value`` is :class:`UnsetValue` + no update of the ``value`` is made. Likewise, if ``coercer`` + is ``None``, no update is made. Update to or from a ``lazy`` + value will also update the ``lazy`` property accordingly. + """ + if other._value is not UnsetValue: # noqa: SLF001 + self._value = other._value # noqa: SLF001 + # we also need to syncronize the lazy eval flag + # so we can do the right thing (TM) with the + # new value + self._lazy = other._lazy # noqa: SLF001 + + if other._coercer: # noqa: SLF001 + self._coercer = other._coercer # noqa: SLF001 + + def __str__(self) -> str: + # wrap the value in the classname to make clear that + # the actual object type is different from the value + return f'{self.__class__.__name__}({self._value})' + + def __repr__(self) -> str: + # wrap the value in the classname to make clear that + # the actual object type is different from the value + return ( + f'{self.__class__.__name__}(' + f'{self.value!r}' + f', coercer={self._coercer!r}' + f', lazy={self._lazy}' + ')' + ) + + def __eq__(self, item: object) -> bool: + """ + This default implementation of comparing for equality only compare the + types, value, and coercer of the two items. If additional criteria are + relevant for derived classes :meth:`__eq__` has to be reimplemented. + """ + if not isinstance(item, type(self)): + return False + return ( + self._lazy == item._lazy + and self._value == item._value + and self._coercer == item._coercer + ) + + def copy(self): + """Return a shallow copy of the instance""" + return copy(self) diff --git a/datasalad/settings/settings.py b/datasalad/settings/settings.py new file mode 100644 index 0000000..30c4b57 --- /dev/null +++ b/datasalad/settings/settings.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from copy import copy +from itertools import chain +from types import MappingProxyType +from typing import ( + TYPE_CHECKING, + Any, +) + +from datasalad.settings.setting import Setting + +if TYPE_CHECKING: # pragma: nocover + from datasalad.settings import Source + + +class Settings: + """Query different sources of configuration settings + + This is query-centered. Manipulation is supported by + by individual configuration source implementations. + This separation is done for two reasons. 1) Query is + a much more frequent operation than write, and + 2) consolidating different sources for read is sensible, + and doable, while a uniform semantics and behavior for + write are complicated due to the inherent differences + across sources. + """ + + def __init__( + self, + sources: dict[str, Source], + ): + # we keep the sources strictly separate. + # the order here matters and represents the + # precedence rule + self._sources = sources + + @property + def sources(self) -> MappingProxyType: + return MappingProxyType(self._sources) + + def __len__(self): + return len(self.keys()) + + def __getitem__(self, key) -> Setting: + # TODO: go from the back + # - start with the Setting class instance we get + # - update a copy of this particular instance with all information + # from sources with higher priority and flatten it across + # sources + # - this gives the most relevant information + # - a default source could decide to run a dynamic default + # function at that point + item: Setting | None = None + for s in reversed(self._sources.values()): + update_item = None + try: + update_item = s[key] + except KeyError: + # source does not have it, proceed + continue + if item is None: + # important to copy the item here, we'd otherwise dp + # in-place modification and destroy the original + # item's integrity + item = copy(update_item) + continue + # we run the update() method of the first item we ever found. + # this will practically make the type produced by the lowest + # precendence source define the behavior. This is typically + # some kind of implementation default + item.update(update_item) + if item is None: + # there was nothing + raise KeyError + return item + + def __contains__(self, key): + return any(key in s for s in self._sources.values()) + + def keys(self) -> set[str]: + return set(chain.from_iterable(s.keys() for s in self._sources.values())) + + def get(self, key: str, default: Any = None) -> Setting: + try: + return self[key] + except KeyError: + if isinstance(default, Setting): + return default + return Setting(value=default) + + def getall(self, key: str, default: Any = None) -> tuple[Setting, ...]: + # no flattening, get all from all + items: tuple[Setting, ...] = () + for s in reversed(self._sources.values()): + if key in s: + # we checked before, no need to handle a default here + if hasattr(s, 'getall'): + items = (*items, *s.getall(key)) + else: + items = (*items, s[key]) + if not items: + if isinstance(default, Setting): + return (default,) + return (Setting(value=default),) + return items diff --git a/datasalad/settings/source.py b/datasalad/settings/source.py new file mode 100644 index 0000000..41ef224 --- /dev/null +++ b/datasalad/settings/source.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from abc import ( + ABC, + abstractmethod, +) +from typing import ( + TYPE_CHECKING, + Any, +) + +from datasalad.settings.setting import Setting + +if TYPE_CHECKING: + from collections.abc import Collection + + +class Source(ABC): + @property + @abstractmethod + def is_writable(self) -> bool: + """Flag whether configuration item values can be set at the source""" + + @abstractmethod + def load(self) -> None: + """Implements loading items from the configuration source. + + It is expected that after calling this method, an instance of + this source reports on configuration items according to the + latest/current state of the source. + + No side-effects are implied. Particular implementations may + even choose to have this method be a no-op. + + Importantly, calling this method does not imply a `reinit()``. + If a from-scratch reload is desired, ``reinit()`` must be called + explicitly. + """ + + @abstractmethod + def reinit(self) -> None: + """Re-initialize source instance + + Re-initializing is resetting any state of the source interface instance + such that a subsequent ``load()`` fully synchronizes the reporting of + settings with the state of the underlying source. Calling ``reinit()`` + does **not** imply resetting of the underlying settings source, such as + removing all settings from the source. + """ + + @abstractmethod + def __getitem__(self, key: str) -> Setting: + """ """ + + @abstractmethod + def __setitem__(self, key: str, value: Setting) -> None: + """ """ + + @abstractmethod + def get(self, key: str, default: Any = None) -> Setting: + """ """ + + @abstractmethod + def keys(self) -> Collection: + """ """ + + def __len__(self) -> int: + return len(self.keys()) + + def __contains__(self, key: str) -> bool: + return key in self.keys() + + +class CachingSource(Source): + """Source that loads settings at once into a cache + + On first access of any setting the ``reinit()`` and + ``load()`` methods of a subclass are called. + + On load, an implementation can use the standard ``__setitem__()`` method of + this class directly to populate the cache. Any subsequent + read access is reported directly from this cache. + + Subclasses should generally reimplement ``__setitem__()`` + to call the base class implementation in addition to + setting a value in the actual source. + """ + + def __init__(self) -> None: + super().__init__() + self.__items: dict[str, Setting | tuple[Setting, ...]] | None = None + + @property + def _items(self) -> dict[str, Setting | tuple[Setting, ...]]: + if self.__items is None: + self.reinit() + self.load() + if TYPE_CHECKING: + assert self.__items is not None + return self.__items + + def reinit(self) -> None: + # particular implementations may not use this facility, + # but it is provided as a convenience. Maybe factor + # it out into a dedicated subclass even. + self.__items = {} + + def __len__(self) -> int: + return len(self._items) + + def __getitem__(self, key: str) -> Setting: + val = self._items[key] + if isinstance(val, tuple): + return val[-1] + return val + + def __setitem__(self, key: str, value: Setting) -> None: + if not self.is_writable: + raise NotImplementedError + self._items[key] = value + + def __delitem__(self, key: str): + del self._items[key] + + def __contains__(self, key: str) -> bool: + return key in self._items + + def keys(self) -> Collection[str]: + return self._items.keys() + + def get(self, key, default: Any = None) -> Setting: + try: + val = self._items[key] + except KeyError: + if isinstance(default, Setting): + return default + return Setting(value=default) + if isinstance(val, tuple): + return val[-1] + return val + + def add(self, key: str, value: Setting) -> None: + if not self.is_writable: + raise NotImplementedError + if key in self: + self._items[key] = (*self.getall(key), value) + else: + self._items[key] = value + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self._items!r})' + + def __str__(self) -> str: + return ''.join( + ( + f'{self.__class__.__name__}(', + *( + # we use the pristine value here to avoid issues + # with validation/coercion failures when rendering + # sources + f'{k}={",".join(repr(val.pristine_value) for val in v)}' + if isinstance(v, tuple) + else f'{k}={v.pristine_value!r}' + for k, v in self._items.items() + ), + ')', + ) + ) + + def getall(self, key: str, default: Any = None) -> tuple[Setting, ...]: + try: + val = self._items[key] + except KeyError: + if isinstance(default, Setting): + return (default,) + return (Setting(value=default),) + if isinstance(val, tuple): + return val + return (val,) + + # TODO: __iter__ + + +class InMemorySettings(CachingSource): + is_writable = True + + def load(self) -> None: + """Does nothing + + An instance of :class:`InMemorySettings` has no underlying source + to load from. + """ + + def __str__(self): + return 'InMemory' diff --git a/datasalad/settings/tests/__init__.py b/datasalad/settings/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/datasalad/settings/tests/test_defaults.py b/datasalad/settings/tests/test_defaults.py new file mode 100644 index 0000000..6eb4918 --- /dev/null +++ b/datasalad/settings/tests/test_defaults.py @@ -0,0 +1,53 @@ +import logging +import sys +from os.path import dirname + +from ..defaults import Defaults +from ..setting import Setting + + +def test_defaultsrc(caplog): + d = Defaults() + assert str(d) == 'Defaults' + + # smoke test NO-OP method + d.load() + + target_key = 'some.key' + orig_value = 'mike' + updated_value = 'allnew' + + assert target_key not in d + assert d.get(target_key, 'default').value == 'default' + assert d.get(target_key, Setting('default2')).value == 'default2' + d[target_key] = Setting(orig_value) + assert d[target_key].value == orig_value + assert 'Resetting' not in caplog.text + with caplog.at_level(logging.DEBUG): + # we get a debug message when a default is reset + d[target_key] = Setting(updated_value) + assert 'Resetting' in caplog.text + assert d[target_key].value == updated_value + del d[target_key] + assert target_key not in d + + d[target_key] = Setting(orig_value) + assert len(d) == 1 + d.reinit() + assert target_key not in d + assert len(d) == 0 + + +def test_defaultsrc_dynamic(): + d = Defaults() + target_key = 'some.key' + dynset = Setting( + lambda: sys.executable, + coercer=dirname, + lazy=True, + ) + assert dynset.value == dirname(sys.executable) + + d[target_key] = dynset + item = d[target_key] + assert item.value == dirname(sys.executable) diff --git a/datasalad/settings/tests/test_env.py b/datasalad/settings/tests/test_env.py new file mode 100644 index 0000000..dbb6950 --- /dev/null +++ b/datasalad/settings/tests/test_env.py @@ -0,0 +1,112 @@ +from os import ( + environ, +) +from os import ( + name as os_name, +) +from unittest.mock import patch + +import pytest + +from ..env import Environment +from ..setting import Setting + + +def test_envsrc(): + assert str(Environment()) == 'Environment' + assert str(Environment(var_prefix='DATALAD_')) == 'Environment[DATALAD_]' + assert repr(Environment()) == 'Environment()' + + # smoke test NO-OP methods + env = Environment() + env.reinit() + env.load() + + +def test_envsrc_illegal_keys(): + env = Environment() + # prevent any accidental modification + with patch.dict(environ, {}): + with pytest.raises(ValueError, match='illegal'): + env['mustnothave=char'] = 'some' + with pytest.raises(ValueError, match='illegal'): + env['mustnothave\0char'] = 'some' + + +# traditional datalad name transformation approach +class DataladLikeEnvironment(Environment): + def get_key_from_varname(self, name: str) -> str: + return name.replace('__', '-').replace('_', '.').casefold() + + def get_varname_from_key(self, key: str) -> str: + # note that this is not actually a real inverse transform + return key.replace('.', '_').replace('-', '__').upper() + + +def test_envsrc_get(monkeypatch): + target_key = 'datalad.chunky-monkey.feedback' + target_value = 'ohmnomnom' + absurd_must_be_absent_key = 'nobody.would.use.such.a.key' + with monkeypatch.context() as m: + m.setenv('DATALAD_CHUNKY__MONKEY_FEEDBACK', 'ohmnomnom') + env = DataladLikeEnvironment(var_prefix='DATALAD_') + assert target_key in env.keys() # noqa: SIM118 + assert target_key in env + assert env.get(target_key).value == target_value + # default is wrapped into Setting if needed + assert env.get(absurd_must_be_absent_key, target_value).value is target_value + assert ( + env.get(absurd_must_be_absent_key, Setting(value=target_value)).value + is target_value + ) + # assert env.getvalue(target_key) == target_value + # assert env.getvalue(absurd_must_be_absent_key) is None + assert len(env) + + +def test_envsrc_get_ambiguous(monkeypatch, caplog): + target_key = 'datalad.chunky-monkey.feedback' + target_value = 'ohmnomnom' + with monkeypatch.context() as m: + # define two different setting that map on the same key + # with datalad's mapping rules + m.setenv('DATALAD_CHUNKY__monkey_FEEDBACK', 'würg') + m.setenv('DATALAD_CHUNKY__MONKEY_FEEDBACK', 'ohmnomnom') + env = DataladLikeEnvironment(var_prefix='DATALAD_') + # negative test to make the next one count + assert 'multiple' not in caplog.text + # we still get the key, but more or less random which one + assert env[target_key].value in ('würg', target_value) + # we saw a log message complaining about the ambiguous + # key + if os_name not in ('os2', 'nt'): + # not testing on plaforms where Python handles vars + # in case insensitive manner + assert 'multiple' in caplog.text + + +def test_envsrc_set(): + env = Environment() + + with patch.dict(environ, {}): + env['some.key'] = Setting(value='mike') + assert 'some.key' in env + + # the instance is stateless, restoring the original + # env removes any knowledge of the key + assert 'some.key' not in env + + +def test_envsrc_set_matching_transformed(): + env = DataladLikeEnvironment(var_prefix='DATALAD_') + env_name = 'DATALAD_SOME_KEY' + orig_value = 'mike' + updated_value = 'allnew' + + with patch.dict(environ, {env_name: orig_value}): + assert 'datalad.some.key' in env + assert env['datalad.some.key'].value == orig_value + env['datalad.some.key'] = Setting(updated_value) + # the new value is set for the inverse-transformed + # variable name + assert environ.get(env_name) == updated_value diff --git a/datasalad/settings/tests/test_setting.py b/datasalad/settings/tests/test_setting.py new file mode 100644 index 0000000..1dca89b --- /dev/null +++ b/datasalad/settings/tests/test_setting.py @@ -0,0 +1,34 @@ +import pytest + +from ..setting import Setting + + +def test_setting(): + with pytest.raises(ValueError, match='callable required'): + Setting(5, lazy=True) + + test_val = 5 + item = Setting(lambda: test_val, lazy=True) + assert item.is_lazy is True + assert item.value == test_val + + assert 'lambda' in str(item) + + test_val = 4 + item.update(Setting(str(test_val), coercer=int)) + assert item.is_lazy is False + assert item.value == test_val + + item.update(Setting(coercer=float)) + assert item.value == float(test_val) + + +def test_setting_derived_copy(): + class MySetting(Setting): + def __init__(self, allnew: str): + self.allnew = allnew + + target = 'dummy' + ms = MySetting(target) + ms_c = ms.copy() + assert ms_c.allnew == target diff --git a/datasalad/settings/tests/test_settings.py b/datasalad/settings/tests/test_settings.py new file mode 100644 index 0000000..43a0b2f --- /dev/null +++ b/datasalad/settings/tests/test_settings.py @@ -0,0 +1,66 @@ +import sys + +import pytest + +from ..defaults import Defaults +from ..setting import Setting +from ..settings import Settings +from ..source import InMemorySettings + + +def test_settings(): + man = Settings( + { + 'mem1': InMemorySettings(), + 'mem2': InMemorySettings(), + 'defaults': Defaults(), + } + ) + + assert list(man.sources.keys()) == ['mem1', 'mem2', 'defaults'] + assert len(man) == 0 + target_key = 'some.key' + assert target_key not in man + with pytest.raises(KeyError): + man[target_key] + + man.sources['defaults'][target_key] = Setting('0', coercer=int) + assert man[target_key].value == 0 + + man.sources['mem2'][target_key] = Setting('1', coercer=float) + man.sources['mem1'][target_key] = Setting('2') + + coerced_target = 2.0 + item = man[target_key] + assert item.value == coerced_target + assert item.coercer == float + + vals = man.getall(target_key) + assert isinstance(vals, tuple) + # one per source here + # TODO: enhance test case to have a multi-value setting in a single source + nsources = 3 + assert len(vals) == nsources + assert [v.value for v in vals] == [0, 1.0, '2'] + + vals = man.getall('idonotexist') + assert isinstance(vals, tuple) + assert vals == (Setting(None),) + + vals = man.getall('idonotexist', Setting(True)) + assert isinstance(vals, tuple) + assert vals == (Setting(True),) + + assert man.get('idonotexist').value is None + assert ( + man.get( + 'idonotexist', + # makes little actual sense, but exercises a lazy + # default setting + Setting( + lambda: sys.executable, + lazy=True, + ), + ).value + is sys.executable + ) diff --git a/datasalad/settings/tests/test_source.py b/datasalad/settings/tests/test_source.py new file mode 100644 index 0000000..7879bb1 --- /dev/null +++ b/datasalad/settings/tests/test_source.py @@ -0,0 +1,68 @@ +from ..setting import Setting +from ..source import ( + CachingSource, + InMemorySettings, + Source, +) + + +class DummyCachingSource(CachingSource): + is_writable = True + + def load(self): + pass + + +def test_inmemorysrc(): + mem = InMemorySettings() + assert str(mem) == 'InMemory' + + target_key = 'dummy' + mem[target_key] = Setting('dummy') + assert mem.getall('dummy') == (Setting('dummy'),) + assert str(InMemorySettings()) == 'InMemory' + + +def test_cachingsource(): + ds = DummyCachingSource() + ds['mike'] = Setting('one') + assert ds['mike'] == Setting('one') + assert ds.get('mike') == Setting('one') + assert str(ds) == "DummyCachingSource(mike='one')" + assert repr(ds) == ( + 'DummyCachingSource(' "{'mike': Setting('one', coercer=None, lazy=False)})" + ) + + ds.add('mike', Setting('two')) + assert ds['mike'].value == 'two' + assert ds.get('mike').value == 'two' + assert ds.getall('mike') == (Setting('one'), Setting('two')) + + assert ds.getall('nothere') == (Setting(None),) + assert ds.getall('nothere', Setting(True)) == (Setting(True),) + + +def test_settings_base_default_methods(): + class DummySource(Source): + is_writable = True + + def load(self): # pragma: no cover + pass + + def reinit(self): # pragma: no cover + pass + + def get(self, _, __): # pragma: no cover + pass + + def __setitem__(self, key, value): # pragma: no cover + pass + + def __getitem__(self, key): # pragma: no cover + pass + + def keys(self): + return {'mykey'} + + src = DummySource() + assert 'mykey' in src diff --git a/docs/index.rst b/docs/index.rst index 85b8bc8..006d9c2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -78,6 +78,7 @@ Also see the :ref:`modindex`. runners iterable_subprocess itertools + settings Why ``datasalad``?