From 2a0646f82d0f7d7947c6977f1a751fd3c5090d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Fri, 4 Sep 2026 20:51:39 +0100 Subject: [PATCH 1/2] Count an OutOfOrderTableProxy as table-like in candidate validation _validate_table_candidate() flags a type mismatch whenever one side of a key is a Table/AoT and the other isn't, to catch a genuine table-vs-scalar redefinition. But once an out-of-order table gets merged, the existing value under that key is an OutOfOrderTableProxy, not a Table or AoT instance, even though it's still very much a table. A later fragment extending that same key (itself a plain Table) then looks like a type conflict and gets rejected with KeyAlreadyPresent, even though tomllib and earlier tomlkit releases both accept the document fine. Reported in #571 with a minimal repro and the root cause already narrowed down to this exact check, introduced by the concrete+super validation in #530. Treating OutOfOrderTableProxy as table-like here fixes the regression without touching the concrete/super logic below it. A related report, #577, hits a different KeyAlreadyPresent inside OutOfOrderTableProxy's own AoT-extension merge path rather than this validation check, so it's a separate bug and stays open. --- tests/test_toml_document.py | 24 ++++++++++++++++++++++++ tomlkit/container.py | 9 ++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 7ba61f5e..54ba9fd7 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -643,6 +643,30 @@ def test_valid_out_of_order_independent_tables() -> None: assert doc.as_string() == "[a]\nx=1\n[zz]\n[a.b]\nc=1\n" +def test_out_of_order_table_extended_after_intervening_header() -> None: + # Regression test for a real-world case: an out-of-order table (here + # tool.ruff.lint) gets extended with a further sibling after some + # unrelated header (tool.poetry.source) came in between. The existing + # entry shows up as an OutOfOrderTableProxy rather than a bare Table, and + # the concrete/super type check used to treat that as a type mismatch + # against the new Table candidate, rejecting a document tomllib accepts. + source = ( + "[tool.ruff]\n" + "[tool.ruff.lint.a]\n" + "[tool.ruff.lint]\n" + "[[tool.poetry.source]]\n" + "[tool.ruff.lint.b]\n" + ) + doc = parse(source) + assert doc.unwrap() == { + "tool": { + "ruff": {"lint": {"a": {}, "b": {}}}, + "poetry": {"source": [{}]}, + } + } + assert doc.as_string() == source + + def test_set_value_on_out_of_order_table_with_empty_concrete_part() -> None: # A super table defined after its sub-table (the "defining a super-table # afterward is ok" spec example) leaves an empty concrete `[x]` part. diff --git a/tomlkit/container.py b/tomlkit/container.py index 8ff30d98..3eca9eee 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -424,7 +424,14 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: if k in current.value._map: existing = current.value.item(k) - if isinstance(existing, (Table, AoT)) != isinstance(v, (Table, AoT)): + # An out-of-order table already merged under this key shows up + # as an OutOfOrderTableProxy rather than a Table/AoT instance, + # even though it represents one or more concrete tables. Count + # it as table-like here too, or a later fragment of that same + # table gets rejected as a type mismatch against its own kind. + existing_is_table = isinstance(existing, (Table, AoT, OutOfOrderTableProxy)) + candidate_is_table = isinstance(v, (Table, AoT, OutOfOrderTableProxy)) + if existing_is_table != candidate_is_table: raise KeyAlreadyPresent(k) if k.is_dotted(): raise TOMLKitError("Redefinition of an existing table") From 2920b1a471ae4d821fdc91c4f4345adb94eef127 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:52:14 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tomlkit/container.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 3eca9eee..d4b3bbb2 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -429,7 +429,9 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: # even though it represents one or more concrete tables. Count # it as table-like here too, or a later fragment of that same # table gets rejected as a type mismatch against its own kind. - existing_is_table = isinstance(existing, (Table, AoT, OutOfOrderTableProxy)) + existing_is_table = isinstance( + existing, (Table, AoT, OutOfOrderTableProxy) + ) candidate_is_table = isinstance(v, (Table, AoT, OutOfOrderTableProxy)) if existing_is_table != candidate_is_table: raise KeyAlreadyPresent(k)