Source code for scm.plams.core.settings

import textwrap
import contextlib
from functools import wraps

__all__ = ['Settings', 'ig']


[docs]class Settings(dict): """Automatic multi-level dictionary. Subclass of built-in class :class:`dict`. The shortcut dot notation (``s.basis`` instead of ``s['basis']``) can be used for keys that: * are strings * don't contain whitespaces * begin with a letter or an underscore * don't both begin and end with two or more underscores. Iteration follows lexicographical order (via :func:`sorted` function) Methods for displaying content (:meth:`~object.__str__` and :meth:`~object.__repr__`) are overridden to recursively show nested instances in easy-readable format. Regular dictionaries (also multi-level ones) used as values (or passed to the constructor) are automatically transformed to |Settings| instances:: >>> s = Settings({'a': {1: 'a1', 2: 'a2'}, 'b': {1: 'b1', 2: 'b2'}}) >>> s.a[3] = {'x': {12: 'q', 34: 'w'}, 'y': 7} >>> print(s) a: 1: a1 2: a2 3: x: 12: q 34: w y: 7 b: 1: b1 2: b2 """
[docs] def __init__(self, *args, **kwargs): dict.__init__(self, *args, **kwargs) for k,v in self.items(): if isinstance(v, dict): self[k] = Settings(v) if isinstance(v, list): self[k] = [Settings(i) if isinstance(i, dict) else i for i in v]
[docs] def copy(self): """Return a new instance that is a copy of this one. Nested |Settings| instances are copied recursively, not linked. In practice this method works as a shallow copy: all "proper values" (leaf nodes) in the returned copy point to the same objects as the original instance (unless they are immutable, like ``int`` or ``tuple``). However, nested |Settings| instances (internal nodes) are copied in a deep-copy fashion. In other words, copying a |Settings| instance creates a brand new "tree skeleton" and populates its leaf nodes with values taken directly from the original instance. This behavior is illustrated by the following example:: >>> s = Settings() >>> s.a = 'string' >>> s.b = ['l','i','s','t'] >>> s.x.y = 12 >>> s.x.z = {'s','e','t'} >>> c = s.copy() >>> s.a += 'word' >>> s.b += [3] >>> s.x.u = 'new' >>> s.x.y += 10 >>> s.x.z.add(1) >>> print(c) a: string b: ['l', 'i', 's', 't', 3] x: y: 12 z: set([1, 's', 'e', 't']) >>> print(s) a: stringword b: ['l', 'i', 's', 't', 3] x: u: new y: 22 z: set([1, 's', 'e', 't']) This method is also used when :func:`python3:copy.copy` is called. """ ret = Settings() for name in self: if isinstance(self[name], Settings): ret[name] = self[name].copy() else: ret[name] = self[name] return ret
[docs] def soft_update(self, other): """Update this instance with data from *other*, but do not overwrite existing keys. Nested |Settings| instances are soft-updated recursively. In the following example ``s`` and ``o`` are previously prepared |Settings| instances:: >>> print(s) a: AA b: BB x: y1: XY1 y2: XY2 >>> print(o) a: O_AA c: O_CC x: y1: O_XY1 y3: O_XY3 >>> s.soft_update(o) >>> print(s) a: AA #original value s.a not overwritten by o.a b: BB c: O_CC x: y1: XY1 #original value s.x.y1 not overwritten by o.x.y1 y2: XY2 y3: O_XY3 *Other* can also be a regular dictionary. Of course in that case only top level keys are updated. Shortcut ``A += B`` can be used instead of ``A.soft_update(B)``. """ for name in other: if isinstance(other[name], Settings): if name not in self: self[name] = other[name].copy() elif isinstance(self[name], Settings): self[name].soft_update(other[name]) elif name not in self: self[name] = other[name] return self
[docs] def update(self, other): """Update this instance with data from *other*, overwriting existing keys. Nested |Settings| instances are updated recursively. In the following example ``s`` and ``o`` are previously prepared |Settings| instances:: >>> print(s) a: AA b: BB x: y1: XY1 y2: XY2 >>> print(o) a: O_AA c: O_CC x: y1: O_XY1 y3: O_XY3 >>> s.update(o) >>> print(s) a: O_AA #original value s.a overwritten by o.a b: BB c: O_CC x: y1: O_XY1 #original value s.x.y1 overwritten by o.x.y1 y2: XY2 y3: O_XY3 *Other* can also be a regular dictionary. Of course in that case only top level keys are updated. """ for name in other: if isinstance(other[name], Settings): if name not in self or not isinstance(self[name], Settings): self[name] = other[name].copy() else: self[name].update(other[name]) else: self[name] = other[name]
[docs] def merge(self, other): """Return new instance of |Settings| that is a copy of this instance soft-updated with *other*. Shortcut ``A + B`` can be used instead of ``A.merge(B)``. """ ret = self.copy() ret.soft_update(other) return ret
[docs] def find_case(self, key): """Check if this instance contains a key consisting of the same letters as *key*, but possibly with different case. If found, return such a key. If not, return *key*. When |Settings| are used in case-insensitive contexts, this helps preventing multiple occurences of the same key with different case:: >>> s = Settings() >>> s.system.key1 = 'value1' >>> s.System.key2 = 'value2' >>> print(s) System: key2: value2 system: key1: value1 >>> t = Settings() >>> t.system.key1 = 'value1' >>> t[t.find_case('System')].key2 = 'value2' >>> print(t) system: key1: value1 key2: value2 """ lowkey = key.lower() for k in self: if k.lower() == lowkey: return k return key
[docs] def as_dict(self): """Return a copy of this instance with all |Settings| replaced by regular Python dictionaries. """ d = {} for k, v in self.items(): if isinstance(v, Settings): d[k] = v.as_dict() elif isinstance(v, list): d[k] = [i.as_dict() if isinstance(i, Settings) else i for i in v] else: d[k] = v return d
[docs] @classmethod def supress_missing(cls): """A context manager for temporary disabling the :meth:`.Settings.__missing__` magic method: all calls now raising a :exc:`KeyError`. As a results, attempting to access keys absent from an arbitrary |Settings| instance will raise a :exc:`KeyError`, thus reverting to the default dictionary behaviour. .. note:: The :meth:`.Settings.__missing__` method is (temporary) supressed at the class level to ensure consistent invokation by the Python interpreter. See also `special method lookup`_. Example: .. code:: python >>> s = Settings() >>> with s.supress_missing(): ... s.a.b.c = True KeyError: 'a' >>> s.a.b.c = True >>> print(s.a.b.c) True .. _`special method lookup`: https://docs.python.org/3/reference/datamodel.html#special-method-lookup """ return SupressMissing(cls)
[docs] def get_nested(self, key_tuple, supress_missing=False): """Retrieve a nested value by, recursively, iterating through this instance using the keys in *key_tuple*. The :meth:`.Settings.__getitem__` method is called recursively on this instance until all keys in key_tuple are exhausted. Setting *supress_missing* to ``True`` will internally open the :meth:`.Settings.supress_missing` context manager, thus raising a :exc:`KeyError` if a key in *key_tuple* is absent from this instance. .. code:: python >>> s = Settings() >>> s.a.b.c = True >>> value = s.get_nested(('a', 'b', 'c')) >>> print(value) True """ s = self with contextlib.suppress() if not supress_missing else s.supress_missing(): for k in key_tuple: s = s[k] return s
[docs] def set_nested(self, key_tuple, value, supress_missing=False): """Set a nested value by, recursively, iterating through this instance using the keys in *key_tuple*. The :meth:`.Settings.__getitem__` method is called recursively on this instance, followed by :meth:`.Settings.__setitem__`, until all keys in key_tuple are exhausted. Setting *supress_missing* to ``True`` will internally open the :meth:`.Settings.supress_missing` context manager, thus raising a :exc:`KeyError` if a key in *key_tuple* is absent from this instance. .. code:: python >>> s = Settings() >>> s.set_nested(('a', 'b', 'c'), True) >>> print(s) a: b: c: True """ s = self with contextlib.suppress() if not supress_missing else s.supress_missing(): for k in key_tuple[:-1]: s = s[k] s[key_tuple[-1]] = value
[docs] def flatten(self, flatten_list=True): """Return a flattened copy of this instance. New keys are constructed by concatenating the (nested) keys of this instance into tuples. Opposite of the :meth:`.Settings.unflatten` method. If *flatten_list* is ``True``, all nested lists will be flattened as well. Dictionary keys are replaced with list indices in such case. .. code-block:: python >>> s = Settings() >>> s.a.b.c = True >>> print(s) a: b: c: True >>> s_flat = s.flatten() >>> print(s_flat) ('a', 'b', 'c'): True """ if flatten_list: nested_type = (Settings, list) iter_type = lambda x: x.items() if isinstance(x, Settings) else enumerate(x) else: nested_type = Settings iter_type = Settings.items def _concatenate(key_ret, sequence): # Switch from Settings.items() to enumerate() if a list is encountered for k, v in iter_type(sequence): k = key_ret + (k, ) if isinstance(v, nested_type) and v: # Empty lists or Settings instances will return ``False`` _concatenate(k, v) else: ret[k] = v # Changes keys into tuples ret = Settings() _concatenate((), self) return ret
[docs] def unflatten(self, unflatten_list=True): """Return a nested copy of this instance. New keys are constructed by expanding the keys of this instance (*e.g.* tuples) into new nested |Settings| instances. If *unflatten_list* is ``True``, integers will be interpretted as list indices and are used for creating nested lists. Opposite of the :meth:`.Settings.flatten` method. .. code-block:: python >>> s = Settings() >>> s[('a', 'b', 'c')] = True >>> print(s) ('a', 'b', 'c'): True >>> s_nested = s.unflatten() >>> print(s_nested) a: b: c: True """ ret = Settings() for key, value in self.items(): s = ret for k1, k2 in zip(key[:-1], key[1:]): if not unflatten_list: s = s[k1] continue if isinstance(k2, int) and not isinstance(s[k1], list): s[k1] = [] if isinstance(k1, int): # Apply padding to s s += [Settings()] * (k1 - len(s) + 1) s = s[k1] s[key[-1]] = value return ret
#=======================================================================
[docs] def __iter__(self): """Iteration through keys follows lexicographical order. All keys are sorted as if they were strings.""" return iter(sorted(self.keys(), key=str))
[docs] def __missing__(self, name): """When requested key is not present, add it with an empty |Settings| instance as a value. This method is essential for automatic insertions in deeper levels. Without it things like:: >>> s = Settings() >>> s.a.b.c = 12 will not work. The behaviour of this method can be supressed by initializing the :class:`.Settings.supress_missing` context manager. """ self[name] = Settings() return self[name]
[docs] def __contains__(self, name): """Like regular ``__contains`__``, but if the key is an "ig" string, ignore the case.""" if isinstance(name, ig): name = self.find_case(name) return dict.__contains__(self, name)
[docs] def __getitem__(self, name): """Like regular ``__getitem__``, but if the key is an "ig" string, ignore the case.""" if isinstance(name, ig): name = self.find_case(name) return dict.__getitem__(self, name)
[docs] def __setitem__(self, name, value): """Like regular ``__setitem__``, but if the value is a dict, convert it to |Settings|.""" if isinstance(name, ig): name = self.find_case(name) if isinstance(value, dict): value = Settings(value) dict.__setitem__(self, name, value)
[docs] def __delitem__(self, name): """Like regular ``__detitem__``, but if the key is an "ig" string, ignore the case.""" if isinstance(name, ig): name = self.find_case(name) return dict.__delitem__(self, name)
[docs] def __getattr__(self, name): """If name is not a magic method, redirect it to ``__getitem__``.""" if (name.startswith('__') and name.endswith('__')): return dict.__getattribute__(self, name) return self[name]
[docs] def __setattr__(self, name, value): """If name is not a magic method, redirect it to ``__setitem__``.""" if name.startswith('__') and name.endswith('__'): dict.__setattr__(self, name, value) else: self[name] = value
[docs] def __delattr__(self, name): """If name is not a magic method, redirect it to ``__delitem__``.""" if name.startswith('__') and name.endswith('__'): dict.__delattr__(self, name) else: del self[name]
[docs] def _str(self, indent): """Print contents with *indent* spaces of indentation. Recursively used for printing nested |Settings| instances with proper indentation.""" ret = '' for key, value in self.items(): ret += ' '*indent + str(key) + ': \t' if isinstance(value, Settings): ret += '\n' + value._str(indent+len(str(key))+1) else: # Apply consistent indentation at every '\n' character indent_str = ' ' * (2 + indent + len(str(key))) + '\t' ret += textwrap.indent(str(value), indent_str)[len(indent_str):] + '\n' return ret
[docs] def __str__(self): return self._str(0)
__repr__ = __str__ __iadd__ = soft_update __add__ = merge __copy__ = copy
class ig(str): """Special string that makes |Settings| work case-insensitive. Behaves exactly like the built-in `str` type. Usage: ``s = ig('abcdef')``.""" pass class SupressMissing(contextlib.AbstractContextManager): """A context manager for temporary disabling the :meth:`.Settings.__missing__` magic method. See :meth:`Settings.supress_missing` for more details.""" def __init__(self, obj: type): """Initialize the :class:`SupressMissing` context manager.""" # Ensure that obj is a class, not a class instance self.obj = obj if isinstance(obj, type) else type(obj) self.missing = obj.__missing__ def __enter__(self): """Enter the :class:`SupressMissing` context manager: delete :meth:`.Settings.__missing__` at the class level.""" @wraps(self.missing) def __missing__(self, name): raise KeyError(name) # The __missing__ method is replaced for as long as the context manager is open setattr(self.obj, '__missing__', __missing__) def __exit__(self, exc_type, exc_value, traceback): """Exit the :class:`SupressMissing` context manager: reenable :meth:`.Settings.__missing__` at the class level.""" setattr(self.obj, '__missing__', self.missing)