diff --git a/nailgun/nailgun/lcm/context.py b/nailgun/nailgun/lcm/context.py index 27cbfdb97d..f7397570af 100644 --- a/nailgun/nailgun/lcm/context.py +++ b/nailgun/nailgun/lcm/context.py @@ -13,6 +13,7 @@ # under the License. from nailgun import utils +from nailgun.utils.uniondict import UnionDict class TransactionContext(object): @@ -29,11 +30,11 @@ class TransactionContext(object): self.options = kwargs def get_new_data(self, node_id): - return utils.dict_merge(self.new['common'], - self.new['nodes'][node_id]) + return UnionDict(self.new['common'], + self.new['nodes'][node_id]) def get_old_data(self, node_id, task_id): node_info = utils.get_in(self.old, task_id, 'nodes', node_id) if not node_info: return {} - return utils.dict_merge(self.old[task_id]['common'], node_info) + return UnionDict(self.old[task_id]['common'], node_info) diff --git a/nailgun/nailgun/test/unit/test_uniondict.py b/nailgun/nailgun/test/unit/test_uniondict.py new file mode 100644 index 0000000000..038f1084d5 --- /dev/null +++ b/nailgun/nailgun/test/unit/test_uniondict.py @@ -0,0 +1,105 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import yaml + +from oslo_serialization import jsonutils + +from nailgun.test.base import BaseTestCase +from nailgun.utils.uniondict import UnionDict + + +class TestUnionDict(BaseTestCase): + + D1 = {'a': 1, 'b': 2, 'c': {'x': 10}} + D2 = {'b': 3, 'd': 4, 'c': {'y': 20}} + D3 = {'e': 5, 'f': 6, 'c': {'z': 30}} + D = {'a': 1, 'b': 3, 'c': {'x': 10, 'y': 20, 'z': 30}, + 'd': 4, 'e': 5, 'f': 6} + + YAML_STR_EXPECTED = """a: 1 +b: 3 +c: + x: 10 + y: 20 + z: 30 +d: 4 +e: 5 +f: 6""" + + JSON_STR_EXPECTED = ('{"c": {"z": 30, "y": 20, "x": 10}, ' + '"b": 3, "a": 1, "f": 6, "e": 5, "d": 4}') + + def test_base(self): + d1 = {'a': 1, 'b': 2} + d2 = {'c': 3, 'd': 4} + d3 = {'e': 5, 'f': 6} + + d = UnionDict(d1, d2, d3) + self.assertEqual(d['a'], 1) + self.assertEqual(d['b'], 2) + self.assertEqual(d['c'], 3) + self.assertEqual(d['d'], 4) + self.assertEqual(d['e'], 5) + self.assertEqual(d['f'], 6) + self.assertRaises(KeyError, lambda: d[0]) + + def test_override(self): + d1 = {'a': 1, 'b': 1} + d2 = {'a': 2, 'b': 2} + d3 = {'a': 3} + + d = UnionDict(d1, d2, d3) + self.assertEqual(d['a'], 3) + self.assertEqual(d['b'], 2) + + d = UnionDict(d3, d2, d1) + self.assertEqual(d['a'], 1) + self.assertEqual(d['b'], 1) + + def test_override_dict(self): + d1 = {'a': {'x': 10}} + d2 = {'a': 1} + + d = UnionDict(d1, d2) + self.assertEqual(d['a'], 1) + + d = UnionDict(d2, d1) + self.assertEqual(d['a'], {'x': 10}) + + def test_merge(self): + d1 = {'a': {'x': 10}} + d2 = {'a': {'y': 20}} + d3 = {'a': {'z': 30}} + + d = UnionDict(d1, d2, d3) + self.assertIsInstance(d['a'], UnionDict) + self.assertEqual(d['a']['x'], 10) + self.assertEqual(d['a']['y'], 20) + self.assertEqual(d['a']['z'], 30) + + def test_yaml_dump(self): + d = UnionDict(self.D1, self.D2, self.D3) + yaml_str = yaml.dump(d, default_flow_style=False).strip() + self.assertEqual(yaml_str, self.YAML_STR_EXPECTED) + + def test_json_dump(self): + d = UnionDict(self.D1, self.D2, self.D3) + json_str = jsonutils.dumps(d).strip() + self.assertEqual(jsonutils.loads(json_str), + jsonutils.loads(self.JSON_STR_EXPECTED)) + + def test_repr(self): + d = UnionDict(self.D1, self.D2, self.D3) + self.assertEquals(eval(repr(d)), self.D) diff --git a/nailgun/nailgun/utils/uniondict.py b/nailgun/nailgun/utils/uniondict.py new file mode 100644 index 0000000000..02511ab5d1 --- /dev/null +++ b/nailgun/nailgun/utils/uniondict.py @@ -0,0 +1,71 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import collections +import itertools +import yaml + + +class UnionDict(collections.Mapping): + """Object, which acts like read-only union of several dicts. + + This is an object which acts like a deep merge of several + dicts. It's needed to replace real dict, merged with help + of utils.dict_merge in LCM code. + """ + def __init__(self, *dicts): + for d in dicts: + if not isinstance(d, dict): + raise ValueError("UnionDict can only be apllied to dicts.") + + self.dicts = list(dicts) + self.dicts.reverse() + self.keys = set(itertools.chain.from_iterable(dicts)) + + def __getitem__(self, key): + values = [] + for d in self.dicts: + try: + value = d[key] + except KeyError: + continue + + values.append(value) + if not isinstance(value, dict): + return values[0] + + if len(values) == 0: + raise KeyError(key) + elif len(values) == 1: + return values[0] + + values.reverse() + return UnionDict(*values) + + def __iter__(self): + return iter(self.keys) + + def __len__(self): + return len(self.keys) + + def __repr__(self): + items = ['{!r}: {!r}'.format(k, v) for k, v in self.items()] + return '{{{}}}'.format(', '.join(items)) + + +def uniondict_representer(dumper, data): + return dumper.represent_mapping(u'tag:yaml.org,2002:map', data) + + +yaml.add_representer(UnionDict, uniondict_representer)