diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b87c5b..1efb887b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Fix uncontrolled recursion when parsing deeply nested documents: crafted input could crash the process with a `RecursionError`. Values nested more than 100 levels deep and keys with more than 100 dotted fragments now raise `ParseError`. ([#459](https://github.com/python-poetry/tomlkit/issues/459)) - Fix `comment()` producing invalid TOML for a multiline string by prefixing every line with `#`, not just the first. ([#449](https://github.com/python-poetry/tomlkit/issues/449)) - Fix the separator comma being swallowed by a trailing comment when appending a key to a multiline inline table, leaving the new key without a separator so the result no longer round-trips. ([#512](https://github.com/python-poetry/tomlkit/issues/512)) +- Fix a `KeyAlreadyPresent` error when parsing or accessing an out-of-order table whose array-of-tables elements are split across the table's parts. ([#505](https://github.com/python-poetry/tomlkit/issues/505)) ## [0.15.0] - 2026-05-10 diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 8bb69d18..1cdfb9c5 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -602,6 +602,71 @@ def test_unwrap_preserves_raise_on_invalid_out_of_order_fragment() -> None: doc.unwrap() +def test_out_of_order_table_merges_aot_fragments() -> None: + # https://github.com/python-poetry/tomlkit/issues/505 + content = """\ +[hooks] + +[[hooks.Stop]] +matcher = ".*" + +[unrelated] +x = 1 + +[[hooks.Stop]] +matcher = "second" + +[hooks.state] +y = 2 +""" + doc = parse(content) + assert doc.as_string() == content + + hooks = doc["hooks"] + assert list(hooks.keys()) == ["Stop", "state"] + assert len(hooks["Stop"]) == 2 + assert hooks["Stop"][1]["matcher"] == "second" + assert hooks["state"]["y"] == 2 + + # element-level mutation still writes through to the document + hooks["Stop"][1]["matcher"] = "patched" + assert 'matcher = "patched"' in doc.as_string() + + +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 + # parts keep their order and every element is reachable. + content = """\ +[hooks] + +[[hooks.Stop]] +matcher = "a" + +[unrelated1] +x = 1 + +[[hooks.Stop]] +matcher = "b" + +[unrelated2] +y = 2 + +[[hooks.Stop]] +matcher = "c" + +[hooks.state] +z = 3 +""" + doc = parse(content) + assert doc.as_string() == content + + hooks = doc["hooks"] + assert list(hooks.keys()) == ["Stop", "state"] + assert [t["matcher"] for t in hooks["Stop"]] == ["a", "b", "c"] + assert hooks["state"]["z"] == 3 + + def test_out_of_order_tables_are_still_dicts() -> None: content = """ [a.a] diff --git a/tomlkit/container.py b/tomlkit/container.py index fefd76fd..75e09018 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -1004,7 +1004,11 @@ def __init__(self, container: Container, indices: tuple[int, ...]) -> None: self._tables.append(_item) table_idx = len(self._tables) - 1 for k, v in _item.value.body: - self._internal_container._raw_append(k, v) + merged = self._merge_aot_fragment(k, v) + if merged is None: + self._internal_container._raw_append(k, v) + else: + v = merged key_indices = self._tables_map.setdefault(k, []) # type: ignore[arg-type] if table_idx not in key_indices: key_indices.append(table_idx) @@ -1013,6 +1017,39 @@ def __init__(self, container: Container, indices: tuple[int, ...]) -> None: self._internal_container._validate_out_of_order_table() + def _merge_aot_fragment(self, key: Key | None, item: Item) -> AoT | None: + """ + Merge an array-of-tables fragment from a later out-of-order table part. + + An AoT whose elements are split across out-of-order parts of the same + table arrives here once per part; ``_raw_append`` only knows how to + chain duplicate ``Table`` parts and would raise ``KeyAlreadyPresent``. + The fragments are presented as a new merged ``AoT`` referencing the + live element tables, without mutating either fragment (the parts keep + rendering their own elements). + + 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: + return None + idx = internal._map[key] + if isinstance(idx, tuple): + # A tuple index means the key already resolved to several body + # positions, which here only happens for a degenerate collision + # (e.g. a non-AoT part sharing the key). Three or more genuine AoT + # fragments do not reach this branch: each later fragment merges + # into the single growing AoT below, so ``idx`` stays an int. + return None + existing = internal._body[idx][1] + if not isinstance(existing, AoT): + return None + + merged = AoT([*existing.body, *item.body], parsed=True) + internal._body[idx] = (internal._body[idx][0], merged) + dict.__setitem__(internal, key.key, merged.value) + return merged + def unwrap(self) -> dict[str, Any]: return self._internal_container.unwrap()