From ea6e766a83fcbf76f3eba24c482b2b16bb34c581 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:27:05 +0530 Subject: [PATCH 01/18] Keep emptied tables in the serialized output Tables left with no items after removals now emit their header again instead of vanishing from dumps() (#553). --- tomlkit/container.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tomlkit/container.py b/tomlkit/container.py index 9265c740..cef07691 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -37,6 +37,14 @@ def _is_empty_aot(item: Item) -> bool: return isinstance(item, AoT) and not item.body +def _table_is_empty(table: Table) -> bool: + """Whether ``table`` holds no real items (only whitespace or ``Null`` + placeholders left behind by removals).""" + return not any( + not isinstance(v, (Whitespace, Null)) for _, v in table.value.body + ) + + def _emits_header(key: Key, table: Table) -> bool: """Whether rendering ``table`` under ``key`` emits a ``[header]`` line. @@ -750,6 +758,17 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st _key = prefix + "." + _key header_emitted = _emits_header(key, table) + if not header_emitted and _table_is_empty(table): + # A table with no real items left (e.g. after its last key was + # removed) must still emit its header, or the empty table + # silently vanishes from the output while it is still part of + # the document. Fragments of an out-of-order table are skipped + # when a sibling fragment still holds the table's items. + header_emitted = not any( + k == key and v is not table and not _table_is_empty(v) + for k, v in self._body + if isinstance(v, Table) + ) if header_emitted: open_, close = "[", "]" if table.is_aot_element(): From ea49237a86e6803736803818230a270c42542578 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:27:34 +0530 Subject: [PATCH 02/18] Add regression tests for emptied-table serialization Covers dotted-key and explicit tables emptied by removals, and updates the out-of-order deletion expectation (#553). --- tests/test_toml_document.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 84b114b9..8133fa4a 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1480,12 +1480,15 @@ def test_delete_out_of_order_table_key() -> None: """ doc = parse(content) del doc["bar"]["a"] + # The emptied "bar" table is still part of the document, so it keeps + # serializing as an empty table instead of vanishing (#553). assert ( doc.as_string() == """\ [foo] name = "foo" +[bar] [foo.a] name = "foo-a" @@ -1804,3 +1807,29 @@ def test_emptied_array_of_tables_is_relative_to_the_nearest_header() -> None: doc["t"]["u"]["a"].pop() assert doc.as_string() == "[t]\nu.a = []\n" + + +def test_emptied_table_still_serializes_553() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + doc = parse("""\ +x.y.z = 1 + +[[a]] +x = 1 +""") + doc["a"].pop() + doc["x"]["y"].pop("z") + out = doc.as_string() + assert out == "a = []\n[x.y]\n\n" + assert parse(out).unwrap() == doc.unwrap() + + +def test_emptied_explicit_table_still_serializes() -> None: + doc = parse("""\ +[x.y] +z = 1 +""") + del doc["x"]["y"]["z"] + out = doc.as_string() + assert "[x.y]" in out + assert parse(out).unwrap() == doc.unwrap() From 1f32cf308bd49b16450fd6bf7302364ac491eae7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:58:01 +0000 Subject: [PATCH 03/18] [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, 1 insertion(+), 3 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index cef07691..9667d8b7 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -40,9 +40,7 @@ def _is_empty_aot(item: Item) -> bool: def _table_is_empty(table: Table) -> bool: """Whether ``table`` holds no real items (only whitespace or ``Null`` placeholders left behind by removals).""" - return not any( - not isinstance(v, (Whitespace, Null)) for _, v in table.value.body - ) + return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) def _emits_header(key: Key, table: Table) -> bool: From b4f62f37a23cf1fa91d6064b1c6fb4734121218d Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:31:52 +0530 Subject: [PATCH 04/18] Narrow emptied-table rendering to non-dotted keys Tables materialized from dotted keys stay as they were (covered by #590); emptied tables from [table] headers keep their header. --- tomlkit/container.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 9667d8b7..1c471e86 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -40,7 +40,9 @@ def _is_empty_aot(item: Item) -> bool: def _table_is_empty(table: Table) -> bool: """Whether ``table`` holds no real items (only whitespace or ``Null`` placeholders left behind by removals).""" - return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) + return not any( + not isinstance(v, (Whitespace, Null)) for _, v in table.value.body + ) def _emits_header(key: Key, table: Table) -> bool: @@ -756,12 +758,18 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st _key = prefix + "." + _key header_emitted = _emits_header(key, table) - if not header_emitted and _table_is_empty(table): - # A table with no real items left (e.g. after its last key was - # removed) must still emit its header, or the empty table - # silently vanishes from the output while it is still part of - # the document. Fragments of an out-of-order table are skipped - # when a sibling fragment still holds the table's items. + if ( + not header_emitted + and _table_is_empty(table) + and key is not None + and not key.is_dotted() + ): + # A table materialized from [table] headers (not a dotted key) + # with no real items left - e.g. after its last key was removed - + # is still part of the document, so keep its [header] instead of + # letting it vanish from the output. For fragments of an + # out-of-order table, skip this fragment when a sibling fragment + # still holds the table's items, so no duplicate header appears. header_emitted = not any( k == key and v is not table and not _table_is_empty(v) for k, v in self._body From 8ef959a1c780230ea4ae434971cbde5d946f24f8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:01:59 +0000 Subject: [PATCH 05/18] [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, 1 insertion(+), 3 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 1c471e86..75b66dec 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -40,9 +40,7 @@ def _is_empty_aot(item: Item) -> bool: def _table_is_empty(table: Table) -> bool: """Whether ``table`` holds no real items (only whitespace or ``Null`` placeholders left behind by removals).""" - return not any( - not isinstance(v, (Whitespace, Null)) for _, v in table.value.body - ) + return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) def _emits_header(key: Key, table: Table) -> bool: From 28cd0c2fcd381d443d62002453ddb6b1d39e8908 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:32:13 +0530 Subject: [PATCH 06/18] Update emptied-table tests to the non-dotted scope Drop the dotted-key regression test (covered by #590); keep out-of-order and explicit-table cases plus explicit deletion checks. --- tests/test_toml_document.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 8133fa4a..49247eb6 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1809,27 +1809,35 @@ def test_emptied_array_of_tables_is_relative_to_the_nearest_header() -> None: assert doc.as_string() == "[t]\nu.a = []\n" -def test_emptied_table_still_serializes_553() -> None: - # https://github.com/python-poetry/tomlkit/issues/553 +def test_emptied_explicit_table_still_serializes() -> None: doc = parse("""\ -x.y.z = 1 - -[[a]] -x = 1 +[x.y] +z = 1 """) - doc["a"].pop() - doc["x"]["y"].pop("z") + del doc["x"]["y"]["z"] out = doc.as_string() - assert out == "a = []\n[x.y]\n\n" + assert "[x.y]" in out assert parse(out).unwrap() == doc.unwrap() -def test_emptied_explicit_table_still_serializes() -> None: +def test_deleting_last_child_table_keeps_emptied_parent() -> None: doc = parse("""\ [x.y] z = 1 """) - del doc["x"]["y"]["z"] + del doc["x"]["y"] out = doc.as_string() - assert "[x.y]" in out + assert out == "[x]\n" assert parse(out).unwrap() == doc.unwrap() + + +def test_deleting_table_removes_it_from_output() -> None: + doc = parse("""x.y.z = 1 + +[a] +b = 2 +""") + del doc["x"] + assert doc.as_string() == "\n[a]\nb = 2\n" + + From 34a43d63054b93239665d7d93c4915499a80ce00 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:02:21 +0000 Subject: [PATCH 07/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_toml_document.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 49247eb6..002fe738 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1839,5 +1839,3 @@ def test_deleting_table_removes_it_from_output() -> None: """) del doc["x"] assert doc.as_string() == "\n[a]\nb = 2\n" - - From e2920184c6a15692657597229b941273cd7f3484 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:32:42 +0530 Subject: [PATCH 08/18] Format _table_is_empty helper --- tomlkit/container.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 75b66dec..1c471e86 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -40,7 +40,9 @@ def _is_empty_aot(item: Item) -> bool: def _table_is_empty(table: Table) -> bool: """Whether ``table`` holds no real items (only whitespace or ``Null`` placeholders left behind by removals).""" - return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) + return not any( + not isinstance(v, (Whitespace, Null)) for _, v in table.value.body + ) def _emits_header(key: Key, table: Table) -> bool: From dbce689ff052878f15361dd280986288872eb509 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:02:51 +0000 Subject: [PATCH 09/18] [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, 1 insertion(+), 3 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 1c471e86..75b66dec 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -40,9 +40,7 @@ def _is_empty_aot(item: Item) -> bool: def _table_is_empty(table: Table) -> bool: """Whether ``table`` holds no real items (only whitespace or ``Null`` placeholders left behind by removals).""" - return not any( - not isinstance(v, (Whitespace, Null)) for _, v in table.value.body - ) + return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) def _emits_header(key: Key, table: Table) -> bool: From fa7f4360aff79fea84e42de4767a08a820cfaf6e Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:51:34 +0530 Subject: [PATCH 10/18] Compare emptied-table fragments within their own container body The sibling-fragment check looked at the root body, so an unrelated same-named top-level table could suppress the header of an emptied nested table. --- tomlkit/container.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 75b66dec..eecf5de7 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -40,7 +40,9 @@ def _is_empty_aot(item: Item) -> bool: def _table_is_empty(table: Table) -> bool: """Whether ``table`` holds no real items (only whitespace or ``Null`` placeholders left behind by removals).""" - return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) + return not any( + not isinstance(v, (Whitespace, Null)) for _, v in table.value.body + ) def _emits_header(key: Key, table: Table) -> bool: @@ -744,7 +746,13 @@ def as_string(self) -> str: return s - def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> str: + def _render_table( + self, + key: Key, + table: Table, + prefix: str | None = None, + _siblings: list[tuple[Key | None, Item]] | None = None, + ) -> str: cur = "" if table.display_name is not None: @@ -768,9 +776,12 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st # letting it vanish from the output. For fragments of an # out-of-order table, skip this fragment when a sibling fragment # still holds the table's items, so no duplicate header appears. + # Sibling fragments live in the body of the container that owns + # this table, which is not necessarily this container itself. + siblings = self._body if _siblings is None else _siblings header_emitted = not any( k == key and v is not table and not _table_is_empty(v) - for k, v in self._body + for k, v in siblings if isinstance(v, Table) ) if header_emitted: @@ -813,11 +824,11 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st if v.is_super_table(): if k.is_dotted() and not key.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v) + cur += self._render_table(k, v, _siblings=table.value.body) else: - cur += self._render_table(k, v, prefix=_key) + cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) else: - cur += self._render_table(k, v, prefix=_key) + cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -871,11 +882,11 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: if v.is_super_table(): if k.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v) + cur += self._render_table(k, v, _siblings=table.value.body) else: - cur += self._render_table(k, v, prefix=_key) + cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) else: - cur += self._render_table(k, v, prefix=_key) + cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) elif isinstance(v, AoT): assert k is not None cur += self._render_aot(k, v, prefix=_key) From c5de6272f6a3a3cf2b025ab8c4b817625aec95c0 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:51:53 +0530 Subject: [PATCH 11/18] Test emptied nested table beside a same-named top-level table Add test for emptied nested table with same named top-level table. --- tests/test_toml_document.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 002fe738..315bdfdc 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1839,3 +1839,15 @@ def test_deleting_table_removes_it_from_output() -> None: """) del doc["x"] assert doc.as_string() == "\n[a]\nb = 2\n" + + + + +def test_emptied_nested_table_with_same_named_top_level_table() -> None: + # The emptied t.a must not vanish just because an unrelated top-level + # [a] table exists (sibling fragments only share a container body). + doc = parse("[a]\nx=1\n[t.a.b]\nx=1\n") + del doc["t"]["a"]["b"] + out = doc.as_string() + assert out == "[a]\nx=1\n[t.a]\n" + assert parse(out).unwrap() == doc.unwrap() From 0d2f590681970ecb15eb948dd3a7c960c99d5c18 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:22:27 +0000 Subject: [PATCH 12/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_toml_document.py | 2 -- tomlkit/container.py | 20 +++++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 315bdfdc..75ba215d 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1841,8 +1841,6 @@ def test_deleting_table_removes_it_from_output() -> None: assert doc.as_string() == "\n[a]\nb = 2\n" - - def test_emptied_nested_table_with_same_named_top_level_table() -> None: # The emptied t.a must not vanish just because an unrelated top-level # [a] table exists (sibling fragments only share a container body). diff --git a/tomlkit/container.py b/tomlkit/container.py index eecf5de7..c318ddf2 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -40,9 +40,7 @@ def _is_empty_aot(item: Item) -> bool: def _table_is_empty(table: Table) -> bool: """Whether ``table`` holds no real items (only whitespace or ``Null`` placeholders left behind by removals).""" - return not any( - not isinstance(v, (Whitespace, Null)) for _, v in table.value.body - ) + return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) def _emits_header(key: Key, table: Table) -> bool: @@ -826,9 +824,13 @@ def _render_table( # Dotted key inside table cur += self._render_table(k, v, _siblings=table.value.body) else: - cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) + cur += self._render_table( + k, v, prefix=_key, _siblings=table.value.body + ) else: - cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) + cur += self._render_table( + k, v, prefix=_key, _siblings=table.value.body + ) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -884,9 +886,13 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: # Dotted key inside table cur += self._render_table(k, v, _siblings=table.value.body) else: - cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) + cur += self._render_table( + k, v, prefix=_key, _siblings=table.value.body + ) else: - cur += self._render_table(k, v, prefix=_key, _siblings=table.value.body) + cur += self._render_table( + k, v, prefix=_key, _siblings=table.value.body + ) elif isinstance(v, AoT): assert k is not None cur += self._render_aot(k, v, prefix=_key) From 2d3d1af41368a9d7337487ae7317e3906e4ea505 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:56:17 +0530 Subject: [PATCH 13/18] Fix emptied-table serialization for out-of-order fragments Emptied tables of an out-of-order (fragmented) parent were serialized without checking the other fragments, producing invalid TOML: an emptied fragment could emit its [header] while another fragment still rendered the same table, defining it twice. The dedup now considers the root fragment group (tuple entries in the root container map): an emptied fragment only keeps its header when no fragment of the group renders real content, and a shared per-render set guarantees each emptied header is emitted at most once. Super tables can also keep their header when they would otherwise vanish while still being present in the document. --- tomlkit/container.py | 148 +++++++++++++++++++++++++++++++------------ 1 file changed, 106 insertions(+), 42 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index c318ddf2..86b4a980 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -37,10 +37,35 @@ def _is_empty_aot(item: Item) -> bool: return isinstance(item, AoT) and not item.body +def _table_has_content(table: Table) -> bool: + """Whether ``table`` holds any item that renders content of its own. + + Whitespace and ``Null`` placeholders left behind by removals do not + count, and neither do child tables that are themselves emptied. + """ + for _, v in table.value.body: + if isinstance(v, (Whitespace, Null)): + continue + if isinstance(v, Table): + if _table_has_content(v): + return True + elif isinstance(v, AoT): + if v.body: + return True + else: + return True + return False + + def _table_is_empty(table: Table) -> bool: - """Whether ``table`` holds no real items (only whitespace or ``Null`` - placeholders left behind by removals).""" - return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) + """Whether ``table`` holds no items that render anything at all. + + Unlike ``_table_has_content`` a child table counts here even when it is + itself emptied, because the child still renders its own ``[header]``. + """ + return not any( + not isinstance(v, (Whitespace, Null)) for _, v in table.value.body + ) def _emits_header(key: Key, table: Table) -> bool: @@ -433,11 +458,11 @@ def append( raise KeyAlreadyPresent(key) else: # An existing concrete table (current) is being extended by - # a super-table (item) — e.g. [a] b=1 then [a.b] c=2 out of + # a super-table (item) — e.g. [a] b=1 then [a.b] c=2 out of # order, or [a] b.c=1 then [a.b] d=2. Validate that the # super-table does not redefine any existing key, raising # early at parse time. When validation passes, fall through - # — _raw_append below will create an out-of-order entry and + # — _raw_append below will create an out-of-order entry and # preserve table ordering in the document. assert isinstance(current, Table) self._validate_table_candidate(current, item) @@ -522,7 +547,7 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: 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 — + # 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: if existing_key.is_dotted() and next(iter(existing_key)) == k: @@ -715,6 +740,7 @@ def last_item(self) -> Item | None: def as_string(self) -> str: """Render as TOML string.""" s = "" + empty_headers: set[str] = set() for path, aot in _header_less_aots(self._body): s += _render_empty_aot(path, aot) for k, v in self._body: @@ -728,7 +754,9 @@ def as_string(self) -> str: and "\n" not in v.trivia.indent ): s += "\n" - s += self._render_table(k, v) + s += self._render_table( + k, v, _root_key=k, _empty_headers=empty_headers + ) elif isinstance(v, AoT): if ( s.strip(" ") @@ -736,7 +764,9 @@ def as_string(self) -> str: and "\n" not in v.trivia.indent ): s += "\n" - s += self._render_aot(k, v) + s += self._render_aot( + k, v, _root_key=k, _empty_headers=empty_headers + ) else: s += self._render_simple_item(k, v) else: @@ -749,7 +779,8 @@ def _render_table( key: Key, table: Table, prefix: str | None = None, - _siblings: list[tuple[Key | None, Item]] | None = None, + _root_key: Key | None = None, + _empty_headers: set[str] | None = None, ) -> str: cur = "" @@ -764,24 +795,35 @@ def _render_table( header_emitted = _emits_header(key, table) if ( not header_emitted - and _table_is_empty(table) and key is not None and not key.is_dotted() + and _table_is_empty(table) ): # A table materialized from [table] headers (not a dotted key) - # with no real items left - e.g. after its last key was removed - - # is still part of the document, so keep its [header] instead of - # letting it vanish from the output. For fragments of an - # out-of-order table, skip this fragment when a sibling fragment - # still holds the table's items, so no duplicate header appears. - # Sibling fragments live in the body of the container that owns - # this table, which is not necessarily this container itself. - siblings = self._body if _siblings is None else _siblings - header_emitted = not any( - k == key and v is not table and not _table_is_empty(v) - for k, v in siblings - if isinstance(v, Table) - ) + # with no renderable content left - e.g. after its last key was + # removed - is still part of the document, so keep its [header] + # instead of letting it vanish from the output. Fragments of an + # out-of-order table complicate this: emitting the header for an + # emptied fragment while another fragment still renders the + # table's content produces an invalid duplicate, and several + # emptied fragments must share a single header between them. + root_idx = self._map.get(_root_key if _root_key is not None else key) + if _empty_headers is None: + _empty_headers = set() + if isinstance(root_idx, tuple): + fragments = [ + v + for _, v in (self._body[i] for i in root_idx) + if isinstance(v, Table) + ] + header_emitted = ( + not any(_table_has_content(f) for f in fragments) + and _key not in _empty_headers + ) + else: + header_emitted = _key not in _empty_headers + if header_emitted: + _empty_headers.add(_key) if header_emitted: open_, close = "[", "]" if table.is_aot_element(): @@ -822,15 +864,11 @@ def _render_table( if v.is_super_table(): if k.is_dotted() and not key.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v, _siblings=table.value.body) + cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, v, prefix=_key, _siblings=table.value.body - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, v, prefix=_key, _siblings=table.value.body - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -839,7 +877,13 @@ def _render_table( ): cur += "\n" assert k is not None - cur += self._render_aot(k, v, prefix=_key) + cur += self._render_aot( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) else: cur += self._render_simple_item( k, v, prefix=_key if key.is_dotted() else None @@ -847,7 +891,14 @@ def _render_table( return cur - def _render_aot(self, key: Key, aot: AoT, prefix: str | None = None) -> str: + def _render_aot( + self, + key: Key, + aot: AoT, + prefix: str | None = None, + _root_key: Key | None = None, + _empty_headers: set[str] | None = None, + ) -> str: _key = key.as_string() if prefix is not None: _key = prefix + "." + _key @@ -855,11 +906,22 @@ def _render_aot(self, key: Key, aot: AoT, prefix: str | None = None) -> str: cur = "" _key = decode(_key) for table in aot.body: - cur += self._render_aot_table(table, prefix=_key) + cur += self._render_aot_table( + table, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) return cur - def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: + def _render_aot_table( + self, + table: Table, + prefix: str | None = None, + _root_key: Key | None = None, + _empty_headers: set[str] | None = None, + ) -> str: cur = "" _key = prefix or "" open_, close = "[[", "]]" @@ -884,18 +946,20 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: if v.is_super_table(): if k.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v, _siblings=table.value.body) + cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, v, prefix=_key, _siblings=table.value.body - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, v, prefix=_key, _siblings=table.value.body - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) elif isinstance(v, AoT): assert k is not None - cur += self._render_aot(k, v, prefix=_key) + cur += self._render_aot( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) else: cur += self._render_simple_item(k, v) From 1bc5aa254ede7cede1fb436196bf78e3212dc0e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:26:26 +0000 Subject: [PATCH 14/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tomlkit/container.py | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 86b4a980..a016cc52 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -63,9 +63,7 @@ def _table_is_empty(table: Table) -> bool: Unlike ``_table_has_content`` a child table counts here even when it is itself emptied, because the child still renders its own ``[header]``. """ - return not any( - not isinstance(v, (Whitespace, Null)) for _, v in table.value.body - ) + return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) def _emits_header(key: Key, table: Table) -> bool: @@ -864,11 +862,25 @@ def _render_table( if v.is_super_table(): if k.is_dotted() and not key.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, v, _root_key=_root_key, _empty_headers=_empty_headers + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -946,11 +958,25 @@ def _render_aot_table( if v.is_super_table(): if k.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, v, _root_key=_root_key, _empty_headers=_empty_headers + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) elif isinstance(v, AoT): assert k is not None cur += self._render_aot( From 779ac7793e44057061a885a228da56ca4997b836 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:57:05 +0530 Subject: [PATCH 15/18] Fix emptied-table serialization for out-of-order fragments Emptied tables of an out-of-order (fragmented) parent were serialized without checking the other fragments, producing invalid TOML: an emptied fragment could emit its [header] while another fragment still rendered the same table, defining it twice. The dedup now considers the root fragment group (tuple entries in the root container map): an emptied fragment only keeps its header when no fragment of the group renders real content, and a shared per-render set guarantees each emptied header is emitted at most once. Super tables also keep their header when they would otherwise vanish while still being present in the document. --- tomlkit/container.py | 50 +++++++++++--------------------------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index a016cc52..bd9810b1 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -63,7 +63,9 @@ def _table_is_empty(table: Table) -> bool: Unlike ``_table_has_content`` a child table counts here even when it is itself emptied, because the child still renders its own ``[header]``. """ - return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) + return not any( + not isinstance(v, (Whitespace, Null)) for _, v in table.value.body + ) def _emits_header(key: Key, table: Table) -> bool: @@ -456,11 +458,11 @@ def append( raise KeyAlreadyPresent(key) else: # An existing concrete table (current) is being extended by - # a super-table (item) — e.g. [a] b=1 then [a.b] c=2 out of + # a super-table (item) - e.g. [a] b=1 then [a.b] c=2 out of # order, or [a] b.c=1 then [a.b] d=2. Validate that the # super-table does not redefine any existing key, raising # early at parse time. When validation passes, fall through - # — _raw_append below will create an out-of-order entry and + # - _raw_append below will create an out-of-order entry and # preserve table ordering in the document. assert isinstance(current, Table) self._validate_table_candidate(current, item) @@ -545,7 +547,7 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: 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 — + # 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: if existing_key.is_dotted() and next(iter(existing_key)) == k: @@ -862,25 +864,11 @@ def _render_table( if v.is_super_table(): if k.is_dotted() and not key.is_dotted(): # Dotted key inside table - cur += self._render_table( - k, v, _root_key=_root_key, _empty_headers=_empty_headers - ) + cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, - v, - prefix=_key, - _root_key=_root_key, - _empty_headers=_empty_headers, - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, - v, - prefix=_key, - _root_key=_root_key, - _empty_headers=_empty_headers, - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -958,25 +946,11 @@ def _render_aot_table( if v.is_super_table(): if k.is_dotted(): # Dotted key inside table - cur += self._render_table( - k, v, _root_key=_root_key, _empty_headers=_empty_headers - ) + cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, - v, - prefix=_key, - _root_key=_root_key, - _empty_headers=_empty_headers, - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) else: - cur += self._render_table( - k, - v, - prefix=_key, - _root_key=_root_key, - _empty_headers=_empty_headers, - ) + cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) elif isinstance(v, AoT): assert k is not None cur += self._render_aot( From 158e96abafc52d221ec7bfff486df701d63d8fab Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:27:13 +0000 Subject: [PATCH 16/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tomlkit/container.py | 46 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index bd9810b1..9b17f6d9 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -63,9 +63,7 @@ def _table_is_empty(table: Table) -> bool: Unlike ``_table_has_content`` a child table counts here even when it is itself emptied, because the child still renders its own ``[header]``. """ - return not any( - not isinstance(v, (Whitespace, Null)) for _, v in table.value.body - ) + return not any(not isinstance(v, (Whitespace, Null)) for _, v in table.value.body) def _emits_header(key: Key, table: Table) -> bool: @@ -547,7 +545,7 @@ def _validate_table_candidate(self, current: Table, candidate: Table) -> None: 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 - + # 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: if existing_key.is_dotted() and next(iter(existing_key)) == k: @@ -864,11 +862,25 @@ def _render_table( if v.is_super_table(): if k.is_dotted() and not key.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, v, _root_key=_root_key, _empty_headers=_empty_headers + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -946,11 +958,25 @@ def _render_aot_table( if v.is_super_table(): if k.is_dotted(): # Dotted key inside table - cur += self._render_table(k, v, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, v, _root_key=_root_key, _empty_headers=_empty_headers + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) else: - cur += self._render_table(k, v, prefix=_key, _root_key=_root_key, _empty_headers=_empty_headers) + cur += self._render_table( + k, + v, + prefix=_key, + _root_key=_root_key, + _empty_headers=_empty_headers, + ) elif isinstance(v, AoT): assert k is not None cur += self._render_aot( From 820736cc2545a47d78337b9a242c71686a674b69 Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:57:31 +0530 Subject: [PATCH 17/18] Add regression tests for emptied out-of-order table fragments Cover the cases where a deleted table's parent has later or interleaved fragments of the same root table (the emptied fragment must be dropped so the output never redefines a table), the case where every fragment is emptied (each emptied sub-table keeps exactly one header), and deletion of an implicit table's last child (the emptied parent is kept). --- tests/test_toml_document.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 75ba215d..99e1b974 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1841,6 +1841,8 @@ def test_deleting_table_removes_it_from_output() -> None: assert doc.as_string() == "\n[a]\nb = 2\n" + + def test_emptied_nested_table_with_same_named_top_level_table() -> None: # The emptied t.a must not vanish just because an unrelated top-level # [a] table exists (sibling fragments only share a container body). @@ -1849,3 +1851,36 @@ def test_emptied_nested_table_with_same_named_top_level_table() -> None: out = doc.as_string() assert out == "[a]\nx=1\n[t.a]\n" assert parse(out).unwrap() == doc.unwrap() + + +def test_deleting_table_with_later_fragment_of_same_parent() -> None: + # The emptied fragment of t.a must be dropped: the later [t] fragment + # still renders t's content, so emitting [t.a] would redefine the table. + # (Regression guard: an earlier version of this fix emitted [t.a] here, + # producing invalid TOML with the table defined twice.) + doc = parse("[t.a.b]\nx=1\n[t]\na.c=1\n") + del doc["t"]["a"]["b"] + out = doc.as_string() + assert out == "[t]\na.c=1\n" + assert parse(out).unwrap() == doc.unwrap() + + +def test_deleting_table_with_interleaved_fragment_of_same_parent() -> None: + # Like above, but another table sits between the two fragments of t; + # the surviving [t.a] fragment keeps its content after the emptied one. + doc = parse("[t.a.b]\nx=1\n[q]\nx=1\n[t.a]\nc=1\n") + del doc["t"]["a"]["b"] + out = doc.as_string() + assert out == "[q]\nx=1\n[t.a]\nc=1\n" + assert parse(out).unwrap() == doc.unwrap() + + +def test_all_emptied_fragments_of_out_of_order_parent_each_serialize() -> None: + # When every fragment of an out-of-order parent is emptied, each emptied + # sub-table still gets its header exactly once. + doc = parse("[t.a]\nx=1\n[q]\ny=1\n[t.b]\nz=1\n") + del doc["t"]["a"]["x"] + del doc["t"]["b"]["z"] + out = doc.as_string() + assert out == "[t.a]\n[q]\ny=1\n[t.b]\n" + assert parse(out).unwrap() == doc.unwrap() From 09f6daa6ff7a30cb2fea9ebf2d1be5cb17c8304e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:27:40 +0000 Subject: [PATCH 18/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_toml_document.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 99e1b974..7cc494c4 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1841,8 +1841,6 @@ def test_deleting_table_removes_it_from_output() -> None: assert doc.as_string() == "\n[a]\nb = 2\n" - - def test_emptied_nested_table_with_same_named_top_level_table() -> None: # The emptied t.a must not vanish just because an unrelated top-level # [a] table exists (sibling fragments only share a container body).