Skip to content

Commit 25ca694

Browse files
committed
Fix spurious KeyAlreadyPresent on valid out-of-order child table (#571)
_validate_table_candidate treated only Table/AoT as table-like. When a table is spread across out-of-order headers, its existing entry is an OutOfOrderTableProxy, so the isinstance(existing, (Table, AoT)) check was False while the candidate was a Table. The mismatch raised KeyAlreadyPresent for a document that tomllib and tomlkit <= 0.15.0 accept (a regression introduced by the concrete+super validation in 0.15.1). Recognise OutOfOrderTableProxy as table-like in the type-conflict check, and when the existing entry is a proxy recurse into each of its concrete table fragments so genuine duplicates are still rejected. Adds a regression test and a CHANGELOG entry. Full suite passes (1053 tests). Fixes #571. Signed-off-by: Vladimir Babin <vovababin@gmail.com>
1 parent 7ab7469 commit 25ca694

3 files changed

Lines changed: 44 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Change Log
22

3+
## [Unreleased]
4+
5+
### Fixed
6+
7+
- Fix a regression (since 0.15.1) where a valid out-of-order child table was rejected with `KeyAlreadyPresent` when its concrete parent was declared afterwards and another table separated a later sibling child. The existing entry is an `OutOfOrderTableProxy`, which was not recognised as table-like during table-candidate validation. ([#571](https://github.com/python-poetry/tomlkit/issues/571))
8+
39
## [0.15.1] - 2026-07-17
410

511
### Changed

‎tests/test_api.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,30 @@ def test_parse_rejects_collisions_across_out_of_order_fragments(
480480
parse(content)
481481

482482

483+
def test_parse_accepts_out_of_order_child_with_intervening_table() -> None:
484+
"""A valid out-of-order child table must not be rejected just because its
485+
concrete parent is declared afterwards and another table separates a later
486+
sibling child. Regression for #571 (broken in 0.15.1): the existing entry
487+
is an OutOfOrderTableProxy, which was not recognised as table-like and
488+
raised a spurious KeyAlreadyPresent."""
489+
content = (
490+
"[tool.ruff]\n"
491+
"[tool.ruff.lint.a]\n"
492+
"[tool.ruff.lint]\n"
493+
"[[tool.poetry.source]]\n"
494+
"[tool.ruff.lint.b]\n"
495+
)
496+
doc = parse(content)
497+
assert doc.unwrap() == {
498+
"tool": {
499+
"ruff": {"lint": {"a": {}, "b": {}}},
500+
"poetry": {"source": [{}]},
501+
}
502+
}
503+
# Round-trip must be preserved exactly.
504+
assert dumps(doc) == content
505+
506+
483507
def test_create_super_table_with_table() -> None:
484508
data = {"foo": {"bar": {"a": 1}}}
485509
assert dumps(data) == "[foo.bar]\na = 1\n"

‎tomlkit/container.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,23 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None:
424424

425425
if k in current.value._map:
426426
existing = current.value.item(k)
427-
if isinstance(existing, (Table, AoT)) != isinstance(v, (Table, AoT)):
427+
# An out-of-order table is represented by an OutOfOrderTableProxy
428+
# rather than a Table. It is still table-like, so treat it as a
429+
# table both for the type-conflict check and when recursing into
430+
# the subtree (see #571).
431+
existing_is_table = isinstance(existing, (Table, AoT, OutOfOrderTableProxy))
432+
if existing_is_table != isinstance(v, (Table, AoT)):
428433
raise KeyAlreadyPresent(k)
429434
if k.is_dotted():
430435
raise TOMLKitError("Redefinition of an existing table")
431-
if isinstance(existing, Table) and isinstance(v, Table):
436+
if isinstance(existing, OutOfOrderTableProxy) and isinstance(v, Table):
437+
# The existing table is spread across several out-of-order
438+
# headers; a duplicate (if any) is nested deeper, so keep
439+
# checking against each concrete fragment rather than
440+
# rejecting outright (see #571).
441+
for fragment in existing._tables:
442+
self._validate_table_candidate(fragment, v)
443+
elif isinstance(existing, Table) and isinstance(v, Table):
432444
if not existing.is_super_table() and not v.is_super_table():
433445
# Both sides are concrete `[table]` definitions of the
434446
# same name; the table is declared twice.

0 commit comments

Comments
 (0)