diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 84b114b9..7cc494c4 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,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() diff --git a/tomlkit/container.py b/tomlkit/container.py index 9265c740..9b17f6d9 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -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. @@ -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) @@ -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: @@ -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: @@ -722,7 +752,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(" ") @@ -730,7 +762,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: @@ -738,7 +772,14 @@ 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, + _root_key: Key | None = None, + _empty_headers: set[str] | None = None, + ) -> str: cur = "" if table.display_name is not None: @@ -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(): @@ -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(" ") @@ -803,7 +889,13 @@ 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 @@ -811,7 +903,14 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st 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 @@ -819,11 +918,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 = "[[", "]]" @@ -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)