Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions tests/test_toml_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
39 changes: 38 additions & 1 deletion tomlkit/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):

@frostming frostming Jun 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why skip? it could be separated for more than twice. As in L1044

# 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()

Expand Down
Loading