Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,4 @@ Suggestions and bug reporting:
- d00m514y3r
- Sébastien Weber (seb5g)
- Ward Loos (wrdls)
- Chris (ChrisJr404)
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Changelog
=========

Unreleased
----------

* Adding #263 ``"merge"`` option for ``box_merge_lists`` to merge lists of dictionaries element by element

Version 7.4.1
-------------

Expand Down
32 changes: 32 additions & 0 deletions box/box.py
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,27 @@ def update(self, *args, **kwargs):
self.__convert_and_store(k, kwargs[k])

def merge_update(self, *args, **kwargs):
"""
Recursively update the Box, merging nested dictionaries instead of
overwriting them like the built-in ``dict.update``.

The ``box_merge_lists`` keyword controls how lists sharing a key are
combined:

* ``None`` (default) - the incoming list replaces the existing one
* ``"extend"`` - the incoming items are appended to the existing list
* ``"unique"`` - only incoming items not already present are appended
* ``"merge"`` - lists are merged element by element, recursing into
dictionaries that share the same index so their keys are combined

.. code-block:: python

box_one = Box({"data": [{"a": 1}, {"b": 2}]})
box_one.merge_update({"data": [{"c": 3}]}, box_merge_lists="merge")
# Box({'data': [{'a': 1, 'c': 3}, {'b': 2}]})

:param box_merge_lists: strategy used to merge lists, see above
"""
merge_type = None
if "box_merge_lists" in kwargs:
merge_type = kwargs.pop("box_merge_lists")
Expand Down Expand Up @@ -866,6 +887,17 @@ def convert_and_set(k, v):
if item not in self[k]:
self[k].append(item)
return
if merge_type == "merge" and k in self and isinstance(self[k], list):
for index, item in enumerate(v):
if index < len(self[k]) and isinstance(self[k][index], dict) and isinstance(item, dict):
self[k][index].merge_update(
item, box_merge_lists=merge_type, _force_unfrozen=force_unfrozen
)
elif index < len(self[k]):
self[k][index] = item
else:
self[k].append(item)
return
self.__setitem__(k, v)

if (len(args) + int(bool(kwargs))) > 1:
Expand Down
21 changes: 21 additions & 0 deletions test/test_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,27 @@ def test_merge_list_options(self):
{"app": {"S3": {"S3Service": [{"bucket": "bucket001"}, {"expirationDate": "2099-10-25"}]}}}
), box1

def test_merge_list_merge_option(self):
# Lists of dictionaries are merged element by element
box1 = Box({"data": [{"foo": 1, "foobar": 20}, {"bar": 2}]})
box1.merge_update({"data": [{"foo": 1, "baz": 10}]}, box_merge_lists="merge")
assert box1 == Box({"data": [{"foo": 1, "foobar": 20, "baz": 10}, {"bar": 2}]}), box1

# Extra incoming elements are appended, nested dictionaries recurse
box2 = Box({"app": {"servers": [{"name": "web", "tags": {"a": 1}}]}})
box2.merge_update({"app": {"servers": [{"tags": {"b": 2}}, {"name": "db"}]}}, box_merge_lists="merge")
assert box2 == Box({"app": {"servers": [{"name": "web", "tags": {"a": 1, "b": 2}}, {"name": "db"}]}}), box2

# Non-dict elements at a shared index are overwritten
box3 = Box({"nums": [1, 2, 3]})
box3.merge_update({"nums": [9, 8]}, box_merge_lists="merge")
assert box3.nums == [9, 8, 3]

# Falls back to assignment when the existing value is not a list
box4 = Box({"data": 5})
box4.merge_update({"data": [{"a": 1}]}, box_merge_lists="merge")
assert box4 == Box({"data": [{"a": 1}]}), box4

def test_box_from_empty_yaml(self):
out = Box.from_yaml("---")
assert out == Box()
Expand Down