From 502afb36dcb9ad460eec830ba0b14152b175ed43 Mon Sep 17 00:00:00 2001 From: Dipak Date: Sat, 8 Aug 2026 14:51:03 +0530 Subject: [PATCH] Merge an out-of-order fragment that extends an AoT element [[y.a]] [b.d.x] [[y.a.c]] parses and round-trips, but doc["y"] or doc.unwrap() raised `Key "a" already exists`. `[b.d.x]` makes `y` out of order, so its parts go through OutOfOrderTableProxy. The last header extends the last element of the existing `y.a` array, which means the second `a` fragment arrives as an implicit super table rather than as another AoT. _merge_aot_fragment only recognised AoT + AoT, returned None, and _raw_append then refused the duplicate key. Handle the super-table shape too: copy the array's last element, merge the fragment's body into the copy, and present a new AoT. Copying keeps the fragments the document renders from untouched, as the AoT + AoT path already does. Fixes #577 --- tests/test_toml_document.py | 46 +++++++++++++++++++++++++++++++++++++ tomlkit/container.py | 20 ++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 7ba61f5..9d80081 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -688,6 +688,52 @@ def test_out_of_order_table_merges_aot_fragments() -> None: assert 'matcher = "patched"' in doc.as_string() +def test_out_of_order_table_merges_aot_element_extension() -> None: + # https://github.com/python-poetry/tomlkit/issues/577 + # `[[y.a.c]]` extends the last element of the existing `y.a` array rather + # than adding one, so the second `a` fragment reaches the proxy as an + # implicit super table and not as another AoT. + content = """\ +[[y.a]] +n = 1 + +[b.d.x] + +[[y.a.c]] +m = 2 +""" + doc = parse(content) + assert doc.as_string() == content + + assert doc.unwrap() == { + "y": {"a": [{"n": 1, "c": [{"m": 2}]}]}, + "b": {"d": {"x": {}}}, + } + assert doc["y"]["a"][0]["c"][0]["m"] == 2 + + +def test_out_of_order_table_extends_last_of_several_aot_elements() -> None: + content = """\ +[[y.a]] +n = 1 + +[[y.a]] +n = 2 + +[b.d.x] + +[[y.a.c]] +m = 3 +""" + doc = parse(content) + assert doc.as_string() == content + + assert doc.unwrap() == { + "y": {"a": [{"n": 1}, {"n": 2, "c": [{"m": 3}]}]}, + "b": {"d": {"x": {}}}, + } + + def test_out_of_order_table_merges_three_aot_fragments() -> None: # An AoT split across more than two out-of-order parts merges into a single # AoT: each later fragment is appended to the growing element list, so the diff --git a/tomlkit/container.py b/tomlkit/container.py index 8ff30d9..ab2b4e1 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -1123,7 +1123,9 @@ def _merge_aot_fragment(self, key: Key | None, item: Item) -> AoT | None: Returns the merged ``AoT``, or ``None`` if this is not such a fragment. """ internal = self._internal_container - if key is None or not isinstance(item, AoT) or key not in internal._map: + if key is None or not isinstance(item, (AoT, Table)): + return None + if key not in internal._map: return None idx = internal._map[key] if isinstance(idx, tuple): @@ -1137,7 +1139,21 @@ def _merge_aot_fragment(self, key: Key | None, item: Item) -> AoT | None: if not isinstance(existing, AoT): return None - merged = AoT([*existing.body, *item.body], parsed=True) + if isinstance(item, AoT): + elements = [*existing.body, *item.body] + else: + # The later part extends the array's last element instead of adding + # one: `[[y.a]]` followed by `[[y.a.c]]` reaches here with `a` as an + # implicit super table holding `c`. Copy the element before merging + # so the fragment the document renders from is left alone. + if not item.is_super_table() or not existing.body: + return None + last = copy.deepcopy(existing.body[-1]) + for k, v in item.value.body: + last.value.append(k, copy.deepcopy(v)) + elements = [*existing.body[:-1], last] + + merged = AoT(elements, parsed=True) internal._body[idx] = (internal._body[idx][0], merged) dict.__setitem__(internal, key.key, merged.value) return merged