From 6f411e0bdc887ce6482d9cb22ec93510b5a77069 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:19:37 -0400 Subject: [PATCH] Add a merge option for box_merge_lists merge_update can already combine lists with extend and unique, but there was no way to merge lists of dictionaries the way nested dicts merge. Add a "merge" option that walks both lists by index, recursing into dicts at the same position so their keys combine and appending any extra incoming items. Closes #263. --- AUTHORS.rst | 1 + CHANGES.rst | 5 +++++ box/box.py | 32 ++++++++++++++++++++++++++++++++ test/test_box.py | 21 +++++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 6b44851..00d56c0 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -98,3 +98,4 @@ Suggestions and bug reporting: - d00m514y3r - Sébastien Weber (seb5g) - Ward Loos (wrdls) +- Chris (ChrisJr404) diff --git a/CHANGES.rst b/CHANGES.rst index c4a347d..49c6ba8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 ------------- diff --git a/box/box.py b/box/box.py index 8252643..ab42c71 100644 --- a/box/box.py +++ b/box/box.py @@ -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") @@ -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: diff --git a/test/test_box.py b/test/test_box.py index e5c56e0..63f8e17 100644 --- a/test/test_box.py +++ b/test/test_box.py @@ -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()