Updated: 2023-04-03 Mon 21:57

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

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.