Source code for impactlab_tools.utils.configdict

"""Class for representing tool configuration files
"""
import inspect
# For python2 support:
try:
    from collections import UserDict
    import collections.abc as collections_abc
except ImportError:
    from UserDict import UserDict
    import collections as collections_abc


[docs] def gather_configtree(d, parse_lists=False): """Chains nested-dicts into a connected tree of ConfigDict(s) Parameters ---------- d : dict or MutableMapping Cast to :py:class:`ConfigDict`. Nested dicts within are also recursively cast and assigned parents, reflecting their nested structure. parse_lists : bool, optional If `d` or its children contain a list of dicts, do you want to convert these listed dicts to ConfDicts and assign them parents. This is slow. Note this only parses lists, strictly, not all Sequences. Returns ------- out : ConfigDict Examples -------- .. code-block:: python >>> nest = {'a': 1, 'b': {'a': 2}, 'c': 3, 'd-4': 4, 'e_5': 5, 'F': 6} >>> tree = gather_configtree(nest) >>> tree['b']['a'] 2 Returns the value for "a" in the *nested* dictionary "b". However, if we request a key that is not available in this nested "b" dictionary, it will search through all parents. >>> tree['b']['d-4'] 4 A `KeyError` is only thrown if the search has been exhausted with no matching keys found. """ out = ConfigDict(d) for k, v in out.data.items(): # Replace nested maps with new ConfigDicts if isinstance(v, collections_abc.MutableMapping): out.data[k] = gather_configtree(v, parse_lists=parse_lists) out.data[k].parent = out # If list has mappings, replace mappings with new ConfigDicts if parse_lists and isinstance(v, list): for idx, item in enumerate(v): if isinstance(item, collections_abc.MutableMapping): cd = gather_configtree(item, parse_lists=parse_lists) cd.parent = out out.data[k][idx] = cd return out
[docs] class ConfigDict(UserDict): """Chain-able dictionary to hold projection configurations. A ConfigDict is a dictionary-like interface to a chainmap/linked list. Nested dicts can be access like a traditional dictionary but it searches parent dictionaries for keys:values not found. All string keys normalized, by transforming all characters to lowercase, and all underscores to hyphens. Attributes ---------- parent : ConfigDict or None Parent ConfigDict object to query for keys if not in ``self.data``. key_access_stack : dict Dictionary with values giving the :py:func:`inspect.stack()` from the most recent time a key was retrieved (via ``self.__getitem__()``). data : dict The 'local' dictionary, not in parents. See Also -------- gather_configtree : Chains nested-dicts into a connected tree of ConfigDict(s) Examples -------- .. code-block:: python >>> d = {'a': 1, 'b': {'a': 2}, 'c': 3, 'd-4': 4, 'e_5': 5, 'F': 6} >>> cd = ConfigDict(d) >>> cd['b'] {'a': 2} 'F' key is now lowercase. >>> cd['f'] 6 '_' is now '-' >>> cd['e-5'] 5 Keys that have been accessed. >>> cd.key_access_stack.keys() # doctest: +SKIP dict_keys(['b', 'f', 'e-5']) """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.parent = None for k, v in self.data.items(): # Normalize string keys, only when needed if isinstance(k, str) and (k.isupper() or '_' in k): new_key = self._normalize_key(k) self.data[new_key] = self.pop(k) self.key_access_stack = dict() def __getitem__(self, key): key = self._normalize_key(key) out = super().__getitem__(key) # We don't want to store in key_access_stack if __missing__() was used if key in self.data.keys(): self.key_access_stack[key] = inspect.stack() return out def __missing__(self, key): if self.parent is None: raise KeyError return self.parent[key] def __setitem__(self, key, value): # Note we're not changing `value.parents` if `value` is ConfigDict. key = self._normalize_key(key) super().__setitem__(key, value) @staticmethod def _normalize_key(key): """If `key` is str, make lowercase and replace underscores with hyphens """ if isinstance(key, str): return key.lower().replace('_', '-')
[docs] def accessed_all_keys(self, search='local', parse_lists=False): """Were all the keys used in the config tree? Parameters ---------- search : {'local', 'parents', 'children'} What should the search cover? Options are: ``"local"`` Only check whether keys were used locally (in `self`). ``"parents"`` Recursively check keys in parents, moving up the tree, after checking local keys. ``"children"`` Recursively check keys in children, moving down the tree, after checking local keys. parse_lists : bool, optional If True when `search` is "children", check if self or its children contain a list and check the list for ConfDicts and whether they used their keys. This is slow. Note this only parses lists, strictly, not all Sequences. Returns ------- bool Examples -------- .. code-block:: python >>> d = {'a': 1, 'b': {'a': 2}, 'c': 3, 'd-4': 4, 'e_5': 5, 'F': 6} >>> root_config = gather_configtree(d) >>> child_config = root_config['b'] >>> child_config['a'] 2 We can check whether all the keys in `child_config` have been accessed. >>> child_config.accessed_all_keys() True Same but also checking that all keys up the tree in parents have been used. >>> child_config.accessed_all_keys('parents') False Several keys in root_config were not accessed, so False is returned. Can also check key use locally and down the tree in nested, child ConfigDict instances. >>> root_config.accessed_all_keys('children') False ...which is still False in this case -- all keys in nested child_config have been used, but not all of the local keys in root_config have been used. """ search = str(search) search_options = ('local', 'parents', 'children') if search not in search_options: raise ValueError(f'`search` must be in {search_options}') local_access = set(self.key_access_stack.keys()) local_keys = set(self.data.keys()) all_used = local_access == local_keys # Using a "fail fast" strategy... if all_used is False: return False if search == 'parents': # Recursively check parents keys, if any haven't been used, # immediately return False. if self.parent is not None: parent_used = self.parent.accessed_all_keys( search=search, parse_lists=parse_lists, ) if parent_used is False: return False elif search == 'children': # Recursively check children keys, if any haven't been used, # immediately return False. for k, v in self.data.items(): if parse_lists and isinstance(v, list): for item in v: try: child_used = item.accessed_all_keys( search=search, parse_lists=parse_lists, ) if child_used is False: return False except AttributeError: continue continue try: child_used = v.accessed_all_keys(search=search, parse_lists=parse_lists) if child_used is False: return False except AttributeError: continue return True
[docs] def merge(self, x, xparent=False): """Merge, returning new copy Parameters ---------- x : ConfigDict or dict xparent : bool, optional Attach ``x.parent`` to ``out.parent``? If False, attaches ``self.parent``. Only works if `x` is :py:class:`ConfigDict`. Return ------ out : ConfigDict Merged ConfigDict, using copied values from ``self``. """ out = self.copy() out.update(x) if xparent is True: out.parent = x.parent return out