From fe44aab819296de9977516c23f6557caccfab473 Mon Sep 17 00:00:00 2001 From: Dipak Date: Sat, 8 Aug 2026 14:47:49 +0530 Subject: [PATCH] Accept a table extending an out-of-order child of an out-of-order table [tool.ruff] [tool.ruff.lint.a] [tool.ruff.lint] [[tool.poetry.source]] [tool.ruff.lint.b] raised `Key "lint" already exists` since 0.15.1, though tomllib and 0.15.0 accept it. Extending an existing concrete table with a super table runs _validate_table_candidate, which looks each candidate key up with Container.item(). `lint` is itself split across two parts of `ruff`, so item() returns an OutOfOrderTableProxy, not a Table -- and the `isinstance(existing, (Table, AoT)) != isinstance(v, (Table, AoT))` guard read that as a table being replaced by a plain value. Give the proxy its own branch: compare against the merged view in its internal container, and keep the duplicate-definition check by asking whether any of its parts is concrete. The recursion now walks Containers rather than Tables, which is all the checks ever used. Fixes #571 --- tests/test_toml_document.py | 58 +++++++++++++++++++++++++++++++++++++ tomlkit/container.py | 34 ++++++++++++++++++---- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 7ba61f5..af7193d 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -643,6 +643,64 @@ 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_extend_out_of_order_child_of_out_of_order_table() -> None: + # https://github.com/python-poetry/tomlkit/issues/571 + # `lint` is itself out of order inside `ruff` ([tool.ruff.lint.a] then + # [tool.ruff.lint]), so validating the later [tool.ruff.lint.b] fragment + # looks `lint` up and gets an OutOfOrderTableProxy back rather than a + # Table. That must not be read as a type change. + content = """\ +[tool.ruff] +[tool.ruff.lint.a] +[tool.ruff.lint] +[[tool.poetry.source]] +[tool.ruff.lint.b] +""" + doc = parse(content) + + assert doc.unwrap() == { + "tool": { + "ruff": {"lint": {"a": {}, "b": {}}}, + "poetry": {"source": [{}]}, + } + } + assert doc.as_string() == content + + +def test_reject_duplicate_child_of_out_of_order_table() -> None: + # The counterpart of the above: the last header redefines the concrete + # [tool.ruff.lint] table, which is invalid and must still be rejected + # even though `lint` is reached through a proxy. + with pytest.raises(ParseError): + parse( + "[tool.ruff]\n" + "[tool.ruff.lint.a]\n" + "[tool.ruff.lint]\n" + "[[tool.poetry.source]]\n" + "[tool.ruff.lint]\n" + ) + + +def test_extend_out_of_order_child_at_depth() -> None: + content = """\ +[t.r] +[t.r.l.a] +[t.r.l] +[z] +[t.r.l.b.c] +[q] +[t.r.l.b.d] +""" + doc = parse(content) + + assert doc.unwrap() == { + "t": {"r": {"l": {"a": {}, "b": {"c": {}, "d": {}}}}}, + "z": {}, + "q": {}, + } + assert doc.as_string() == content + + 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 8ff30d9..d1dab6a 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -418,12 +418,34 @@ def append( return self def _validate_table_candidate(self, current: Table, candidate: Table) -> None: - for k, v in candidate.value.body: + self._validate_container_candidate(current.value, candidate.value) + + def _validate_container_candidate( + self, current: Container, candidate: Container + ) -> None: + for k, v in candidate.body: if k is None: continue - if k in current.value._map: - existing = current.value.item(k) + if k in current._map: + existing = current.item(k) + if isinstance(existing, OutOfOrderTableProxy): + # `k` is itself already split across out-of-order parts, so + # `item()` hands back a proxy rather than a Table. Compare + # against the merged view the proxy holds instead of + # treating it as a non-table value. + if not isinstance(v, Table): + raise KeyAlreadyPresent(k) + if k.is_dotted(): + raise TOMLKitError("Redefinition of an existing table") + if not v.is_super_table() and any( + not part.is_super_table() for part in existing._tables + ): + raise KeyAlreadyPresent(k) + self._validate_container_candidate( + existing._internal_container, v.value + ) + continue if isinstance(existing, (Table, AoT)) != isinstance(v, (Table, AoT)): raise KeyAlreadyPresent(k) if k.is_dotted(): @@ -435,20 +457,20 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: raise KeyAlreadyPresent(k) # One side is still an implicit/super table, so a duplicate # (if any) is nested deeper - keep checking the subtree. - self._validate_table_candidate(existing, v) + self._validate_container_candidate(existing.value, v.value) continue if not k.is_dotted(): # Even when the candidate key itself is not dotted, an # existing dotted key may already use it as a prefix — # e.g. [a] b.c=1 then [a.b] d=2 (b prefixes b.c). - for existing_key in current.value._map: + for existing_key in current._map: if existing_key.is_dotted() and next(iter(existing_key)) == k: raise TOMLKitError("Redefinition of an existing table") continue head = next(iter(k)) - if head in current.value._map: + if head in current._map: raise TOMLKitError("Redefinition of an existing table") def _raw_append(self, key: Key | None, item: Item) -> None: