Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ea6e766
Keep emptied tables in the serialized output
mayur-dhavan Oct 25, 2026
ea49237
Add regression tests for emptied-table serialization
mayur-dhavan Oct 25, 2026
1f32cf3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
b4f62f3
Narrow emptied-table rendering to non-dotted keys
mayur-dhavan Oct 25, 2026
8ef959a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
28cd0c2
Update emptied-table tests to the non-dotted scope
mayur-dhavan Oct 25, 2026
34a43d6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
e292018
Format _table_is_empty helper
mayur-dhavan Oct 25, 2026
dbce689
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
fa7f436
Compare emptied-table fragments within their own container body
mayur-dhavan Oct 25, 2026
c5de627
Test emptied nested table beside a same-named top-level table
mayur-dhavan Oct 25, 2026
0d2f590
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
2d3d1af
Fix emptied-table serialization for out-of-order fragments
mayur-dhavan Oct 25, 2026
1bc5aa2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
779ac77
Fix emptied-table serialization for out-of-order fragments
mayur-dhavan Oct 25, 2026
158e96a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
820736c
Add regression tests for emptied out-of-order table fragments
mayur-dhavan Oct 25, 2026
09f6daa
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions tests/test_toml_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -1804,3 +1807,78 @@ 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_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()


def test_deleting_last_child_table_keeps_emptied_parent() -> None:
doc = parse("""\
[x.y]
z = 1
""")
del doc["x"]["y"]
out = doc.as_string()
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"


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()


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()
164 changes: 147 additions & 17 deletions tomlkit/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +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 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:
"""Whether rendering ``table`` under ``key`` emits a ``[header]`` line.

Expand Down Expand Up @@ -427,11 +456,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)
Expand Down Expand Up @@ -516,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:
Expand Down Expand Up @@ -709,6 +738,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:
Expand All @@ -722,23 +752,34 @@ 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(" ")
and not s.strip(" ").endswith("\n")
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:
s += self._render_simple_item(k, v)

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,
_root_key: Key | None = None,
_empty_headers: set[str] | None = None,
) -> str:
cur = ""

if table.display_name is not None:
Expand All @@ -750,6 +791,37 @@ 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 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 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():
Expand Down Expand Up @@ -790,11 +862,25 @@ 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, _root_key=_root_key, _empty_headers=_empty_headers
)
else:
cur += self._render_table(k, v, prefix=_key)
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)
cur += self._render_table(
k,
v,
prefix=_key,
_root_key=_root_key,
_empty_headers=_empty_headers,
)
elif isinstance(v, AoT):
if (
cur.strip(" ")
Expand All @@ -803,27 +889,51 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st
):
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
)

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

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 = "[[", "]]"
Expand All @@ -848,14 +958,34 @@ 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, _root_key=_root_key, _empty_headers=_empty_headers
)
else:
cur += self._render_table(k, v, prefix=_key)
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)
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)

Expand Down
Loading