diff --git a/tests/test_items.py b/tests/test_items.py index 27a1c6e2..8e2f3da0 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -1258,6 +1258,46 @@ def test_adding_to_nested_dotted_key_inside_inline_table() -> None: assert parse(rendered).as_string() == rendered +def test_deleting_last_key_under_dotted_key_in_inline_table() -> None: + # The emptied dotted table still exists, so it must render as an empty + # inline table; emitting nothing leaves a dangling separator and produces + # invalid TOML like ``{a = 1, , d = 1}``. + doc = parse("t = {a = 1, b.c = 2, d = 3}\n") + del doc["t"]["b"]["c"] + rendered = doc.as_string() + assert rendered == "t = {a = 1, b = {}, d = 3}\n" + assert parse(rendered) == {"t": {"a": 1, "b": {}, "d": 3}} + assert parse(rendered).as_string() == rendered + + doc = parse("t = {b.c = 2, a = 1}\n") + del doc["t"]["b"]["c"] + rendered = doc.as_string() + assert rendered == "t = {b = {}, a = 1}\n" + assert parse(rendered) == {"t": {"b": {}, "a": 1}} + + doc = parse("t = {a = 1, b.c = 2}\n") + del doc["t"]["b"]["c"] + rendered = doc.as_string() + assert rendered == "t = {a = 1, b = {}}\n" + assert parse(rendered) == {"t": {"a": 1, "b": {}}} + + doc = parse("t = {b.c = 2}\n") + del doc["t"]["b"]["c"] + rendered = doc.as_string() + assert rendered == "t = {b = {}}\n" + assert parse(rendered) == {"t": {"b": {}}} + + +def test_deleting_nested_key_under_dotted_key_in_inline_table() -> None: + doc = parse("t = {x = 1, a.b.c = 2, y = 3}\n") + del doc["t"]["a"]["b"]["c"] + + rendered = doc.as_string() + assert rendered == "t = {x = 1, a.b = {}, y = 3}\n" + assert parse(rendered) == {"t": {"x": 1, "a": {"b": {}}, "y": 3}} + assert parse(rendered).as_string() == rendered + + def test_appending_to_comma_first_array_does_not_double_separator() -> None: doc = parse( """\ diff --git a/tomlkit/items.py b/tomlkit/items.py index 30d8a4f2..039b5b0e 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -2181,8 +2181,11 @@ def as_string(self) -> str: if k.is_dotted() and isinstance(v, Table): # A table materialized from a dotted key renders one # `prefix.child = value` pair per child, so that keys added - # after parsing stay attached to the dotted prefix. - rendered = ", ".join(self._render_dotted(k, v)) + # after parsing stay attached to the dotted prefix. When all + # children were removed the table itself still exists, so it + # must render as an empty inline table to stay valid TOML. + dotted = self._render_dotted(k, v) + rendered = ", ".join(dotted) if dotted else f"{k.as_string()} = {{}}" else: rendered = ( f"{k.as_string() + ('.' if k.is_dotted() else '')}" @@ -2220,7 +2223,13 @@ def _render_dotted(self, key: Key, table: Table) -> list[str]: if k is None: continue if isinstance(v, Table): - parts.extend(f"{prefix}{sub}" for sub in self._render_dotted(k, v)) + sub = self._render_dotted(k, v) + if sub: + parts.extend(f"{prefix}{s}" for s in sub) + else: + # The nested table exists but renders nothing after its + # last child was removed; emit it as an empty inline table. + parts.append(f"{prefix}{k.as_string()} = {{}}") else: trail = v.trivia.trail.replace("\n", "") parts.append(