From c97232cf5d9efab59642c302c3446824823d797d Mon Sep 17 00:00:00 2001 From: Dipak Date: Sun, 19 Jul 2026 09:37:34 +0530 Subject: [PATCH] Render an emptied array of tables as an empty array _render_aot looped over the AoT body to emit [[key]] headers, so an array of tables with no elements left rendered as nothing at all and the key was dropped from the output. Fall back to the inline `key = []` form, matching what an empty array built through the API already renders as. TOML only reads bare key/value pairs before the first table header, so these keys are hoisted there; left in body order an emptied array of tables sitting after a table would be parsed back as a key of that table. Non-empty arrays of tables are unaffected and still render in place. --- tests/test_toml_document.py | 41 ++++++++++++++++++++++++++++++++++++ tomlkit/container.py | 42 ++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 7ba61f5..141b478 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1666,3 +1666,44 @@ def test_scalar_is_not_captured_by_table_rendered_from_dotted_key() -> None: doc["z"] = 2 assert doc.as_string() == "a.b = 1\nz = 2\n" + + +def test_emptied_array_of_tables_renders_as_empty_array() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # An array of tables with no elements left has no `[[key]]` header to + # render, so it must fall back to the inline `key = []` form. Rendering + # nothing dropped the key entirely. + doc = parse("[[a]]\nx = 1\n") + doc["a"].pop() + + assert doc.as_string() == "a = []\n" + assert parse(doc.as_string()) == {"a": []} + + +def test_emptied_array_of_tables_is_hoisted_above_table_headers() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # TOML only reads bare key/value pairs before the first table header, so + # the inline fallback has to be emitted there rather than in body order. + # Left in place it would be parsed back as a key of the preceding table. + doc = parse("[t]\nq = 2\n\n[[a]]\nx = 1\n") + doc["a"].pop() + + assert parse(doc.as_string()) == {"t": {"q": 2}, "a": []} + assert doc.as_string().index("a = []") < doc.as_string().index("[t]") + + +def test_emptied_array_of_tables_keeps_preceding_scalars() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + doc = parse("v = 9\n\n[[a]]\nx = 1\n") + doc["a"].pop() + + assert parse(doc.as_string()) == {"v": 9, "a": []} + + +def test_non_empty_array_of_tables_is_not_hoisted() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # Only emptied arrays of tables change form; ordinary ones must round-trip + # byte for byte. + content = "[t]\nq = 2\n\n[[a]]\nx = 1\n" + + assert parse(content).as_string() == content diff --git a/tomlkit/container.py b/tomlkit/container.py index 8ff30d9..5f54dab 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -630,10 +630,36 @@ def last_item(self) -> Item | None: return self._body[-1][1] return None + def _is_empty_aot(self, item: Item) -> bool: + return isinstance(item, AoT) and not item.body + def as_string(self) -> str: """Render as TOML string.""" s = "" - for k, v in self._body: + # An emptied array of tables has no ``[[key]]`` header left to render, + # so it falls back to the inline ``key = []`` form. TOML only reads + # bare key/value pairs before the first table header, so such keys are + # hoisted there instead of being rendered in body order. + hoisted = [ + (k, v) for k, v in self._body if k is not None and self._is_empty_aot(v) + ] + first_header = next( + ( + i + for i, (k, v) in enumerate(self._body) + if isinstance(v, (Table, AoT)) and not self._is_empty_aot(v) + ), + None, + ) + if first_header is None or not hoisted: + hoisted = [] + + for i, (k, v) in enumerate(self._body): + if hoisted and i == first_header: + for hk, hv in hoisted: + s += self._render_aot(hk, hv) + if hoisted and k is not None and self._is_empty_aot(v): + continue if k is not None: if isinstance(v, Table): if ( @@ -745,6 +771,20 @@ def _render_aot(self, key: Key, aot: AoT, prefix: str | None = None) -> str: if prefix is not None: _key = prefix + "." + _key + if not aot.body: + # An array of tables with no elements has no ``[[key]]`` header to + # render, so fall back to the inline empty-array form. Rendering + # nothing would drop the key entirely. + trail = aot.trivia.trail or "\n" + return ( + f"{aot.trivia.indent}" + f"{decode(_key)}" + f" = []" + f"{aot.trivia.comment_ws}" + f"{decode(aot.trivia.comment)}" + f"{trail}" + ) + cur = "" _key = decode(_key) for table in aot.body: