From d397f5b6e1e65b24ee9d8755ac4e568f9fa910ae Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:08:46 +0530 Subject: [PATCH 1/2] Render emptied dotted-key tables in inline tables as empty inline tables --- tomlkit/items.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tomlkit/items.py b/tomlkit/items.py index 30d8a4f2..1d8b6bec 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -2182,7 +2182,14 @@ def as_string(self) -> str: # 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)) + dotted = self._render_dotted(k, v) + if dotted: + rendered = ", ".join(dotted) + else: + # Every child of the dotted key was removed: keep the + # now-empty table rather than emitting nothing, which + # would leave a dangling separator and invalid TOML. + rendered = f"{k.as_string()}{k.sep or '='}{{}}" else: rendered = ( f"{k.as_string() + ('.' if k.is_dotted() else '')}" @@ -2220,7 +2227,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: + # A dotted child whose keys were all removed renders as + # an empty inline table so the output stays valid TOML. + parts.append(f"{prefix}{k.as_string()}{k.sep or '='}{{}}") else: trail = v.trivia.trail.replace("\n", "") parts.append( From 3f16aa16e1c01ee0cdb85ecd0df4018a4f4a744e Mon Sep 17 00:00:00 2001 From: Mayur Dhavan <118877752+mayur-dhavan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:03 +0530 Subject: [PATCH 2/2] Add regression tests for deleting the last dotted key in an inline table --- tests/test_toml_document.py | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 84b114b9..aa21897e 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1804,3 +1804,48 @@ 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_delete_last_dotted_key_in_inline_table_keeps_valid_toml() -> None: + # https://github.com/python-poetry/tomlkit/issues/662 + # Removing the only child of a dotted key in an inline table left a + # dangling separator, producing invalid TOML like ``t = {a="", , d=1}``. + doc = parse('t = {a="", b.c=1, d=1}\n') + del doc["t"]["b"]["c"] + + assert doc.as_string() == 't = {a="", b={}, d=1}\n' + tomllib = pytest.importorskip("tomllib") + assert tomllib.loads(doc.as_string()) == {"t": {"a": "", "b": {}, "d": 1}} + + +def test_delete_last_dotted_key_in_inline_table_key_positions() -> None: + # https://github.com/python-poetry/tomlkit/issues/662 + # The empty slot used to surface as a leading or trailing comma depending + # on where the dotted key sat, and dropped ``b`` entirely when it was the + # only key. + tomllib = pytest.importorskip("tomllib") + + doc = parse("t = {a=1, b.c=1}\n") + del doc["t"]["b"]["c"] + assert doc.as_string() == "t = {a=1, b={}}\n" + tomllib.loads(doc.as_string()) + + doc = parse("t = {b.c=1, a=1}\n") + del doc["t"]["b"]["c"] + assert doc.as_string() == "t = {b={}, a=1}\n" + tomllib.loads(doc.as_string()) + + doc = parse("t = {b.c=1}\n") + del doc["t"]["b"]["c"] + assert doc.as_string() == "t = {b={}}\n" + tomllib.loads(doc.as_string()) + + +def test_delete_last_nested_dotted_key_in_inline_table_keeps_valid_toml() -> None: + # https://github.com/python-poetry/tomlkit/issues/662 + doc = parse('t = {a="", b.c.d=1, e=1}\n') + del doc["t"]["b"]["c"]["d"] + + assert doc.as_string() == 't = {a="", b.c={}, e=1}\n' + tomllib = pytest.importorskip("tomllib") + assert tomllib.loads(doc.as_string()) == {"t": {"a": "", "b": {"c": {}}, "e": 1}}