Config class using ChainMaps
ChainMaps in Python can be used to create config classes for a library/codebase as it can provide a clean way to have user configuration and default configuration bundled together. Here we explore the possibility of a config class such that it can satisfy the following requirements
[ ]
Validation for keys happens on using__setitem__()
(including while initializing)[ ]
track what values have been set and provide a way to ask if a given key has had its value changed away from the baseline[ ]
a way to tell the object that its current state should be considered the new baseline[ ]
a way to move a key back to the baseline value[ ]
provide a way to access (namespaced) sub-sets of the configuration[ ]
validation on the keys (must be in expected list) and values (must be of expected type)[ ]
bulk update / restore of keys with setdefault style filtering (please set this key only if it has never been updated)[ ]
ability to lock keys (both “soft lock” that can be unlocked and “hard-locked” that can not?)
Now, we can either make the class inherit from ChainMap
or have virtual
inheritance. We’ll try to take a look at both cases.
1. Direct inheritance
from collections import ChainMap, defaultdict validator = lambda x: x class Config(ChainMap): def __init__(self, *args, **kwargs): """ Config class. Arguments --------- args : iterable, Mapping Initial mapping to be used as default config. kwargs Key-value pairs for config values. """ super().__init__(defaultdict(dict)) self.update(*args, **kwargs) self.maps = [{}, *self.maps] @property def keylist(self): keys = [] for key, val in self.items(): if isinstance(val, dict): for subkey in val.keys(): keys.append(".".join((key, subkey))) else: keys.append(key) return tuple(keys) def __getitem__(self, key): keys = key.split(".", maxsplit=1) if len(keys) == 2: submap = ChainMap.__getitem__(self, keys[0]) return ChainMap.__getitem__(submap, keys[1]) else: return ChainMap.__getitem__(self, key) def __setitem__(self, key, value): val = validator(value) keys = key.split(".", maxsplit=1) if len(keys) == 2: if keys[0] in self.maps[0]: ChainMap.__getitem__(self, keys[0]).update({keys[1]: val}) else: ChainMap.__setitem__(self, keys[0], {keys[1]: val}) else: ChainMap.__setitem__(self, key, val)
This class can do basic functions of a config class such that dotted keys are stored in a nested dictionary format.
init_mapping = {"music": "bach", "art": "davinci", "favourites.sport": "football", "favourites.book": "PJ", "favourites.show": "CR"} cfg = Config(init_mapping) print(cfg) print(cfg.keylist)
Config({}, defaultdict(<class 'dict'>, {'music': 'bach', 'art': 'davinci', 'favourites': {'sport': 'football', 'book': 'PJ', 'show': 'CR'}})) ('music', 'art', 'favourites.sport', 'favourites.book', 'favourites.show')
After initializing the config, we can update it like any dictionary.
cfg["music"] = "mozart" cfg["favourites.show"] = "DaVinci's Demons" cfg["favourites.food"] = "cheese" print(cfg)
Config({'music': 'mozart', 'favourites': {'show': "DaVinci's Demons", 'food': 'cheese'}}, defaultdict(<class 'dict'>, {'music': 'bach', 'art': 'davinci', 'favourites': {'sport': 'football', 'book': 'PJ', 'show': 'CR'}}))
We can confirm that we get the updated values too
print(cfg["music"], cfg["favourites.sport"], cfg["favourites.show"])
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[61], line 1 ----> 1 print(cfg["music"], cfg["favourites.sport"], cfg["favourites.show"]) Cell In[58], line 35, in Config.__getitem__(self, key) 33 if len(keys) == 2: 34 submap = ChainMap.__getitem__(self, keys[0]) ---> 35 return ChainMap.__getitem__(submap, keys[1]) 36 else: 37 return ChainMap.__getitem__(self, key) File /usr/lib/python3.10/collections/__init__.py:981, in ChainMap.__getitem__(self, key) 980 def __getitem__(self, key): --> 981 for mapping in self.maps: 982 try: 983 return mapping[key] # can't use 'key in mapping' with defaultdict AttributeError: 'dict' object has no attribute 'maps'
2. Virtual Inheritance
from collections.abc import Mapping class VConfig: def __init__(self, *args, **kwargs): self.mapping = ChainMap() self.update(*args, **kwargs) def update(self, other=(), /, **kwds): ''' D.update([E, ]**F) -> None. Update D from mapping/iterable E and F. If E present and has a .keys() method, does: for k in E: D[k] = E[k] If E present and lacks .keys() method, does: for (k, v) in E: D[k] = v In either case, this is followed by: for k, v in F.items(): D[k] = v ''' if isinstance(other, Mapping): for key in other: self.mapping[key] = other[key] elif hasattr(other, "keys"): for key in other.keys(): self.mapping[key] = other[key] else: for key, value in other: self.mapping[key] = value for key, value in kwds.items(): self.mapping[key] = value def __repr__(self): return repr(self.mapping)
vcfg = VConfig(init_mapping) print(vcfg)
ChainMap({'music': 'bach', 'art': 'davinci', 'favourites.sport': 'football', 'favourites.book': 'PJ', 'favourites.show': 'CR'})
3. ChainMap of ChainMaps
I was running into issues with handling the information and its retrieval in both the above cases as moving dictionaries around was getting unnecessarily complicated. Instead, let’s try a ChainMap of ChainMaps. No idea how it’ll actually perform but let’s see.
music_map = ChainMap({}, {'music': 'bach'}) art_map = ChainMap({}, {'art': 'davinci'}) favs_map = ChainMap({}, {"sport": "football", "book": "PJ"}) maplist = ChainMap(music_map, art_map, favs_map) print(maplist)
ChainMap(ChainMap({}, {'music': 'bach'}), ChainMap({}, {'art': 'davinci'}), ChainMap({}, {'sport': 'football', 'book': 'PJ'}))
favs_map["food"] = "cheese" print(maplist)
ChainMap(ChainMap({}, {'music': 'bach'}), ChainMap({}, {'art': 'davinci'}), ChainMap({'food': 'cheese'}, {'sport': 'football', 'book': 'PJ'}))
Okay, this feels a little promising though it does require me to track the inidividual maps too. Can that be done using a separate dictionary?
map_dict = { "music": ChainMap({}, {'music': 'bach'}), "art": ChainMap({}, {'art': 'davinci'}), "favs": ChainMap({}, {"sport": "football", "book": "PJ"}) } maps = ChainMap(*map_dict.values()) print(maps)
ChainMap(ChainMap({}, {'music': 'bach'}), ChainMap({}, {'art': 'davinci'}), ChainMap({}, {'sport': 'football', 'book': 'PJ'}))
map_dict["favs"]["food"] = "cheese" print(maps)
ChainMap(ChainMap({}, {'music': 'bach'}), ChainMap({}, {'art': 'davinci'}), ChainMap({'food': 'cheese'}, {'sport': 'football', 'book': 'PJ'}))
The new key did get added to the chainmap. So this could be a potential avenue…let’s see…
from collections.abc import MutableMapping, Mapping from collections import ChainMap class MapConfig: namespaces = ("music", "art", "favs") def __init__(self, *args, **kwargs): self.namespace_maps = {name: ChainMap({}) for name in self.namespaces} self.update(*args, **kwargs) self.namespace_maps = {name: mapping.new_child() for name, mapping in self.namespace_maps.items()} self.mapping = ChainMap(*self.namespace_maps.values()) def update(self, other=(), /, **kwds): ''' D.update([E, ]**F) -> None. Update D from mapping/iterable E and F. If E present and has a .keys() method, does: for k in E: D[k] = E[k] If E present and lacks .keys() method, does: for (k, v) in E: D[k] = v In either case, this is followed by: for k, v in F.items(): D[k] = v ''' if isinstance(other, Mapping): for key in other: self[key] = other[key] elif hasattr(other, "keys"): for key in other.keys(): self[key] = other[key] else: for key, value in other: self[key] = value for key, value in kwds.items(): self[key] = value def _split_key(self, key, sep="."): keys = key.split(sep, maxsplit=1) return keys, len(keys) def _get(self, key): keys, depth = self._split_key(key) if depth == 1: return self.namespace_maps[key] elif depth == 2: return self.namespace_maps[keys[0]].get(keys[1]) def __getitem__(self, key): # Add validation return self._get(key) def _set(self, key, value): keys, depth = self._split_key(key) if depth == 1: self.namespace_maps[key] = value elif depth == 2: self.namespace_maps[keys[0]][keys[1]] = value def __setitem__(self, key, value): # Add validation self._set(key, value) def __delitem__(self, key): keys, depth = self._split_key(key) if depth == 1: del self.namespace_maps[key] elif depth == 2: del self.namespace_maps[keys[0]][keys[1]] def __iter__(self): """Yield from sorted list of keys""" yield from sorted(self.keys()) def __len__(self): return sum(len(mapping) for mapping in self.namespace_maps) def __repr__(self): return repr(self.mapping) def keys(self): keys = (".".join((space, key)) for space, mapping in self.namespace_maps.items() for key in mapping.keys()) return keys def values(self): for key in self.keys(): yield self[key] def items(self): for key, value in zip(self.keys(), self.values()): yield key, value def pop(self, key): keys, depth = self._split_key(key) if depth == 1: self.mapping.pop() elif depth == 2: self.namespace_mapping[keys[0]].pop(keys[1]) def popitem(self): return self.mapping.popitem() def clear(self): self.mapping.clear() def setdefault(self, key, default=None): self.mapping[key] = default return default def get(self, key, default=None): return self.mapping[key] MutableMapping.register(MapConfig)
__main__.MapConfig
init_map = {"music.artist": "bach", "art.artist": "davinci", "favs.book": "PJ", "favs.show": "CR"} mcfg = MapConfig(init_map) print(mcfg) print(mcfg["music"])
ChainMap(ChainMap({}, {'artist': 'bach'}), ChainMap({}, {'artist': 'davinci'}), ChainMap({}, {'book': 'PJ', 'show': 'CR'})) ChainMap({}, {'artist': 'bach'})
print(list(mcfg.keys())) print(isinstance(mcfg, MutableMapping)) print("music.artist" in mcfg) print("music" in mcfg) print("artist" in mcfg) print("random" in mcfg) mcfg["music.genre"] = "classical" print(list(mcfg.keys())) print(mcfg.popitem()) print(list(mcfg.keys()))
['music.artist', 'art.artist', 'favs.book', 'favs.show'] True True False False False ['music.artist', 'music.genre', 'art.artist', 'favs.book', 'favs.show'] ('genre', 'classical') ['music.artist', 'art.artist', 'favs.book', 'favs.show']
for k, v in mcfg.items(): print(k, v)
music.artist bach art.artist davinci favs.book PJ favs.show CR
This looks promising but it will entail a lot of work as registering a
class as MutableMapping
will require implementing all the mixin methods too.