From 8ff78eeac7c49722036d76c9e4d3858fcc07eee1 Mon Sep 17 00:00:00 2001 From: wayu15244 Date: Wed, 7 Oct 2026 10:26:57 +0700 Subject: [PATCH] fix: allow out-of-order sub-table headers after dotted keys (#632) --- tests/test_toml_document.py | 39 +++++++++++++++++++++++++++++++++++-- tomlkit/container.py | 30 +++++++++++++++++++++------- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 116de352..f9db5c5e 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1680,10 +1680,45 @@ def test_scalar_is_not_captured_by_table_rendered_from_dotted_key() -> None: """ assert doc.as_string() == expected - assert parse(doc.as_string()) == {"z": 2, "a": {"b": 1, "c": {}}} - # A purely inline dotted key still gets the scalar appended after it. doc = parse("a.b = 1\n") doc["z"] = 2 assert doc.as_string() == "a.b = 1\nz = 2\n" + + +def test_out_of_order_table_header_after_dotted_keys() -> None: + # https://github.com/python-poetry/tomlkit/issues/632 + content = """\ +[a] +b.c = 1 +b.d = 2 + +[x] +y = 1 + +[a.b.e] +f = 3 +""" + doc = parse(content) + assert doc.unwrap() == { + "a": {"b": {"c": 1, "d": 2, "e": {"f": 3}}}, + "x": {"y": 1}, + } + assert doc.as_string() == content + + +def test_reject_out_of_order_concrete_table_redefinition() -> None: + # Redefining an explicit concrete table header after an intermediate table must still fail. + content = """\ +[a.b] +c = 1 + +[x] +y = 1 + +[a.b] +d = 2 +""" + with pytest.raises(ParseError, match='Key "b" already exists'): + parse(content) diff --git a/tomlkit/container.py b/tomlkit/container.py index 2f0fc034..d25d51e1 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -421,18 +421,29 @@ def append( self._validate_out_of_order_table(key) return self - def _validate_table_candidate(self, current: Table, candidate: Table) -> None: + def _validate_table_candidate( + self, current: Table | OutOfOrderTableProxy, candidate: Table + ) -> None: + curr_container = ( + current._internal_container + if isinstance(current, OutOfOrderTableProxy) + else current.value + ) for k, v in candidate.value.body: if k is None: continue - if k in current.value._map: - existing = current.value.item(k) - if isinstance(existing, (Table, AoT)) != isinstance(v, (Table, AoT)): + if k in curr_container._map: + existing = curr_container.item(k) + if isinstance( + existing, (Table, AoT, OutOfOrderTableProxy) + ) != isinstance(v, (Table, AoT)): raise KeyAlreadyPresent(k) if k.is_dotted(): raise TOMLKitError("Redefinition of an existing table") - if isinstance(existing, Table) and isinstance(v, Table): + if isinstance(existing, (Table, OutOfOrderTableProxy)) and isinstance( + v, Table + ): if not existing.is_super_table() and not v.is_super_table(): # Both sides are concrete `[table]` definitions of the # same name; the table is declared twice. @@ -446,13 +457,13 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: # 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 curr_container._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 curr_container._map: raise TOMLKitError("Redefinition of an existing table") def _raw_append(self, key: Key | None, item: Item) -> None: @@ -1260,6 +1271,11 @@ def setdefault(self, key: Key | str, default: Any = None) -> Any: self[key] = default return self[key] + def is_super_table(self) -> bool: + if not self._tables: + return False + return all(t.is_super_table() for t in self._tables) + def ends_with_whitespace(it: Any) -> bool: """Returns ``True`` if the given item ``it`` is a ``Table`` or ``AoT`` object