diff --git a/swh/core/config.py b/swh/core/config.py --- a/swh/core/config.py +++ b/swh/core/config.py @@ -7,6 +7,8 @@ import logging import os import yaml +from itertools import chain +from copy import deepcopy logger = logging.getLogger(__name__) @@ -178,6 +180,77 @@ return full_config +def merge_configs(base, other): + """Merge two config dictionaries + + This does merge config dicts recursively, with the rules, for every value + of the dicts (with 'val' not being a dict): + + - None + type -> type + - type + None -> None + - dict + dict -> dict (merged) + - val + dict -> TypeError + - dict + val -> TypeError + - val + val -> val (other) + + so merging + + { + 'key1': { + 'skey1': value1, + 'skey2': {'sskey1': value2}, + }, + 'key2': value3, + } + + with + + { + 'key1': { + 'skey1': value4, + 'skey2': {'sskey2': value5}, + }, + 'key3': value6, + } + + will give: + + { + 'key1': { + 'skey1': value4, # <-- note this + 'skey2': { + 'sskey1': value2, + 'sskey2': value5, + }, + }, + 'key2': value3, + 'key3': value6, + } + + Note that no type checking is done for anything but dicts. + """ + if not isinstance(base, dict) or not isinstance(other, dict): + raise TypeError( + 'Cannot merge a %s with a %s' % (type(base), type(other))) + + output = {} + allkeys = set(chain(base.keys(), other.keys())) + for k in allkeys: + vb = base.get(k) + vo = other.get(k) + + if isinstance(vo, dict): + output[k] = merge_configs(vb is not None and vb or {}, vo) + elif isinstance(vb, dict) and k in other and other[k] is not None: + output[k] = merge_configs(vb, vo is not None and vo or {}) + elif k in other: + output[k] = deepcopy(vo) + else: + output[k] = deepcopy(vb) + + return output + + def swh_config_paths(base_filename): """Return the Software Heritage specific configuration paths for the given filename.""" diff --git a/swh/core/tests/test_config.py b/swh/core/tests/test_config.py --- a/swh/core/tests/test_config.py +++ b/swh/core/tests/test_config.py @@ -223,3 +223,90 @@ assert os.path.exists(conf['path1']), "path1 should still exist!" assert os.path.exists(conf['path2']), "path2 should now exist." + + +def test_merge_config(): + cfg_a = { + 'a': 42, + 'b': [1, 2, 3], + 'c': None, + 'd': {'gheez': 27}, + 'e': { + 'ea': 'Mr. Bungle', + 'eb': None, + 'ec': [11, 12, 13], + 'ed': {'eda': 'Secret Chief 3', + 'edb': 'Faith No More'}, + 'ee': 451, + }, + 'f': 'Janis', + } + cfg_b = { + 'a': 43, + 'b': [41, 42, 43], + 'c': 'Tom Waits', + 'd': None, + 'e': { + 'ea': 'Igorrr', + 'ec': [51, 52], + 'ed': {'edb': 'Sleepytime Gorilla Museum', + 'edc': 'Nils Peter Molvaer'}, + }, + 'g': 'Hüsker Dü', + } + + # merge A, B + cfg_m = config.merge_configs(cfg_a, cfg_b) + assert cfg_m == { + 'a': 43, # b takes precedence + 'b': [41, 42, 43], # b takes precedence + 'c': 'Tom Waits', # b takes precedence + 'd': None, # b['d'] takes precedence (explicit None) + 'e': { + 'ea': 'Igorrr', # a takes precedence + 'eb': None, # only in a + 'ec': [51, 52], # b takes precedence + 'ed': { + 'eda': 'Secret Chief 3', # only in a + 'edb': 'Sleepytime Gorilla Museum', # b takes precedence + 'edc': 'Nils Peter Molvaer'}, # only defined in b + 'ee': 451, + }, + 'f': 'Janis', # only defined in a + 'g': 'Hüsker Dü', # only defined in b + } + + # merge B, A + cfg_m = config.merge_configs(cfg_b, cfg_a) + assert cfg_m == { + 'a': 42, # a takes precedence + 'b': [1, 2, 3], # a takes precedence + 'c': None, # a takes precedence + 'd': {'gheez': 27}, # a takes precedence + 'e': { + 'ea': 'Mr. Bungle', # a takes precedence + 'eb': None, # only defined in a + 'ec': [11, 12, 13], # a takes precedence + 'ed': { + 'eda': 'Secret Chief 3', # only in a + 'edb': 'Faith No More', # a takes precedence + 'edc': 'Nils Peter Molvaer'}, # only in b + 'ee': 451, + }, + 'f': 'Janis', # only in a + 'g': 'Hüsker Dü', # only in b + } + + +def test_merge_config_type_error(): + for v in (1, 'str', None): + with pytest.raises(TypeError): + config.merge_configs(v, {}) + with pytest.raises(TypeError): + config.merge_configs({}, v) + + for v in (1, 'str'): + with pytest.raises(TypeError): + config.merge_configs({'a': v}, {'a': {}}) + with pytest.raises(TypeError): + config.merge_configs({'a': {}}, {'a': v})