From c97232cf5d9efab59642c302c3446824823d797d Mon Sep 17 00:00:00 2001 From: Dipak Date: Sun, 19 Jul 2026 09:37:34 +0530 Subject: [PATCH 1/2] 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: From 84ad727e8e207a01bce267a7dbd66f70dcf00f20 Mon Sep 17 00:00:00 2001 From: Dipak Date: Sun, 9 Aug 2026 09:01:13 +0530 Subject: [PATCH 2/2] Render an emptied nested array of tables with its bare key The inline `key = []` fallback is a bare key/value pair emitted inside the scope its `[[header]]` named, so applying the render prefix nested the key under itself: an emptied `[[t.a]]` rendered as `[t]` / `t.a = []`, which reads back as `t.t.a`. Drop the prefix for the fallback. Bare pairs are also read into whichever table the closest preceding header opened, so the existing root-level hoisting has to happen in every table body, not just the document body. Factor it into `_hoist_empty_aots` and apply it in `_render_table` and `_render_aot_table` too. Five parametrized regressions assert `parse(doc.as_string()).unwrap() == doc.unwrap()` across nesting under a table, either side of a sibling sub-table, two levels down, and inside an array-of-tables element. Co-Authored-By: Claude Opus 5 --- tests/test_toml_document.py | 27 ++++++++++ tomlkit/container.py | 98 +++++++++++++++++++++++-------------- 2 files changed, 89 insertions(+), 36 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 141b478..cb0af6f 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1707,3 +1707,30 @@ def test_non_empty_array_of_tables_is_not_hoisted() -> None: content = "[t]\nq = 2\n\n[[a]]\nx = 1\n" assert parse(content).as_string() == content + + +@pytest.mark.parametrize( + ("content", "empty"), + [ + # Nested directly under a table. + ("[t]\nq = 2\n\n[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # Nested under a table that also has a sibling sub-table after it. + ("[t]\n\n[[t.a]]\nx = 1\n\n[t.b]\ny = 3\n", lambda doc: doc["t"]["a"]), + # ... and before it, so the fallback has to move. + ("[t]\n\n[t.b]\ny = 3\n\n[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # Two levels down. + ("[t]\n\n[t.u]\n\n[[t.u.a]]\nx = 1\n", lambda doc: doc["t"]["u"]["a"]), + # Inside an element of another array of tables. + ("[[e]]\nn = 1\n\n[[e.a]]\nx = 1\n", lambda doc: doc["e"][0]["a"]), + ], +) +def test_emptied_nested_array_of_tables_round_trips(content, empty) -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # The inline fallback is a bare key/value pair emitted inside the scope its + # header named, so it must be written with the bare key. Carrying the + # header prefix over emitted `t.a = []` under `[t]`, which reads back as + # `t.t.a`. + doc = parse(content) + empty(doc).pop() + + assert parse(doc.as_string()).unwrap() == doc.unwrap() diff --git a/tomlkit/container.py b/tomlkit/container.py index 5f54dab..32cadec 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -32,6 +32,47 @@ _NOT_SET = object() +def _is_empty_aot(item: Item) -> bool: + """Whether ``item`` is an array of tables with no elements left.""" + return isinstance(item, AoT) and not item.body + + +def _hoist_empty_aots( + body: list[tuple[Key | None, Item]], +) -> list[tuple[Key | None, Item]]: + """Order a table body so emptied arrays of tables render before any header. + + An emptied array of tables has no ``[[key]]`` header left to render, so it + falls back to the inline ``key = []`` form. TOML reads a bare key/value pair + into whichever table the closest preceding header opened, so one left in + body order after a header would be read back as a key of *that* table + instead of of the table it belongs to. + """ + first_header = next( + ( + i + for i, (_, v) in enumerate(body) + if isinstance(v, (Table, AoT)) and not _is_empty_aot(v) + ), + None, + ) + if first_header is None: + return body + + hoisted = [ + (k, v) for k, v in body[first_header:] if k is not None and _is_empty_aot(v) + ] + if not hoisted: + return body + + rest = [ + (k, v) + for k, v in body[first_header:] + if not (k is not None and _is_empty_aot(v)) + ] + return body[:first_header] + hoisted + rest + + class Container(_CustomDict): # type: ignore[type-arg] """ A container for items within a TOMLDocument. @@ -630,37 +671,14 @@ 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 = "" - # 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 + for k, v in _hoist_empty_aots(self._body): if k is not None: + if _is_empty_aot(v): + s += self._render_aot(k, v) + continue if isinstance(v, Table): if ( s.strip(" ") @@ -733,8 +751,10 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st elif table.trivia.indent == "\n": cur += table.trivia.indent - for k, v in table.value.body: - if isinstance(v, Table): + for k, v in _hoist_empty_aots(table.value.body): + if k is not None and _is_empty_aot(v): + cur += self._render_aot(k, v) + elif isinstance(v, Table): if ( cur.strip(" ") and not cur.strip(" ").endswith("\n") @@ -767,24 +787,28 @@ 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: - _key = key.as_string() - 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. + # + # ``prefix`` is deliberately not applied: the fallback is a bare + # key/value pair emitted inside the scope the prefix names, so + # repeating the prefix would nest the key under itself. trail = aot.trivia.trail or "\n" return ( f"{aot.trivia.indent}" - f"{decode(_key)}" + f"{decode(key.as_string())}" f" = []" f"{aot.trivia.comment_ws}" f"{decode(aot.trivia.comment)}" f"{trail}" ) + _key = key.as_string() + if prefix is not None: + _key = prefix + "." + _key + cur = "" _key = decode(_key) for table in aot.body: @@ -807,8 +831,10 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: f"{table.trivia.trail}" ) - for k, v in table.value.body: - if isinstance(v, Table): + for k, v in _hoist_empty_aots(table.value.body): + if k is not None and _is_empty_aot(v): + cur += self._render_aot(k, v) + elif isinstance(v, Table): assert k is not None if v.is_super_table(): if k.is_dotted():