From c97232cf5d9efab59642c302c3446824823d797d Mon Sep 17 00:00:00 2001 From: Dipak Date: Sun, 19 Jul 2026 09:37:34 +0530 Subject: [PATCH 1/3] 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 7ba61f5e..141b4784 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 8ff30d98..5f54dabc 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 cf5ff57793dcaa0ffe4a43073b346c6a9f91f09b Mon Sep 17 00:00:00 2001 From: Dipak Date: Sun, 9 Aug 2026 09:01:13 +0530 Subject: [PATCH 2/3] 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. --- 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 141b4784..cb0af6f8 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 5f54dabc..32cadec0 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(): From 658cca612aa9dacab24ee4ee5ce430fa228cba09 Mon Sep 17 00:00:00 2001 From: Dipak Date: Mon, 7 Sep 2026 14:07:21 +0530 Subject: [PATCH 3/3] Render an emptied array of tables relative to its nearest header An emptied array of tables falls back to the inline `key = []` form, which is a bare key/value pair: TOML reads it into whichever table the closest preceding header opened. The fallback was written with a bare key and left in body order, so it only landed in the right table when its parent had an explicit header of its own and nothing else came first. Collect the fallbacks of a scope up front instead, walking through the super tables that emit no header, and key each one relative to the scope that will read it. `[[t.a]]` with no `[t]` now renders `t.a = []` rather than moving the key to the root, and `[[t.u.a]]` under `[t]` renders `u.a = []` rather than `t.u.a = []`, which reparsed as `t.t.u.a`. --- tests/test_toml_document.py | 38 ++++++++++ tomlkit/container.py | 145 ++++++++++++++++++------------------ 2 files changed, 112 insertions(+), 71 deletions(-) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index cb0af6f8..be776a14 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1722,6 +1722,23 @@ def test_non_empty_array_of_tables_is_not_hoisted() -> None: ("[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"]), + # Parent table left implicit: there is no `[t]` header to make `a` + # bare, so the fallback has to keep the `t.` prefix. + ("[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # ... two levels of implicit parent. + ("[[t.u.a]]\nx = 1\n", lambda doc: doc["t"]["u"]["a"]), + # ... with an unrelated header before it, which the fallback must not + # be read into. + ("[x]\nq = 1\n\n[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # ... and with a sibling sub-table, so the implicit parent survives + # into the rendered output as a header of its own. + ("[x]\nq = 1\n\n[[t.a]]\nx = 1\n\n[t.b]\ny = 3\n", lambda doc: doc["t"]["a"]), + # Explicit grandparent, implicit parent: the fallback is relative to + # the nearest header, so it is `u.a`, not `t.u.a`. + ("[t]\n\n[[t.u.a]]\nx = 1\n", lambda doc: doc["t"]["u"]["a"]), + ("[t]\n\n[[t.u.a]]\nx = 1\n\n[t.u.b]\ny = 3\n", lambda doc: doc["t"]["u"]["a"]), + # Implicit parent inside an array-of-tables element. + ("[[e]]\nn = 1\n\n[[e.s.a]]\nx = 1\n", lambda doc: doc["e"][0]["s"]["a"]), ], ) def test_emptied_nested_array_of_tables_round_trips(content, empty) -> None: @@ -1734,3 +1751,24 @@ def test_emptied_nested_array_of_tables_round_trips(content, empty) -> None: empty(doc).pop() assert parse(doc.as_string()).unwrap() == doc.unwrap() + + +def test_emptied_array_of_tables_under_implicit_parent_keeps_its_path() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # A super table emits no header, so it opens no scope for the fallback to + # be bare in. Dropping the prefix here moved the key to the root. + doc = parse("[[t.a]]\nx = 1\n") + doc["t"]["a"].pop() + + assert doc.as_string() == "t.a = []\n" + + +def test_emptied_array_of_tables_is_relative_to_the_nearest_header() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # `[t]` is emitted, `t.u` is not, so the fallback is written relative to + # `[t]`. Spelling it in full emitted `t.u.a = []` under `[t]`, which reads + # back as `t.t.u.a`. + doc = parse("[t]\n[[t.u.a]]\nx = 1\n") + doc["t"]["u"]["a"].pop() + + assert doc.as_string() == "[t]\nu.a = []\n" diff --git a/tomlkit/container.py b/tomlkit/container.py index 32cadec0..ed081fce 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -37,40 +37,69 @@ def _is_empty_aot(item: Item) -> bool: 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. +def _emits_header(key: Key, table: Table) -> bool: + """Whether rendering ``table`` under ``key`` emits a ``[header]`` line. - 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. + A super table normally renders as nothing but the path prefix of its + children, so it opens no scope of its own. """ - first_header = next( - ( - i - for i, (_, v) in enumerate(body) - if isinstance(v, (Table, AoT)) and not _is_empty_aot(v) - ), - None, + return ( + not table.is_super_table() + or ( + any( + not isinstance(v, (Table, AoT, Whitespace, Null)) + for _, v in table.value.body + ) + and not key.is_dotted() + ) + or ( + any( + k is not None and k.is_dotted() + for k, v in table.value.body + if isinstance(v, Table) + ) + and not key.is_dotted() + ) ) - 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 +def _header_less_aots( + body: list[tuple[Key | None, Item]], prefix: str = "" +) -> list[tuple[str, AoT]]: + """Every emptied array of tables reachable without crossing a header. + + An emptied array of tables has no ``[[key]]`` header left to render, so it + falls back to the inline ``key = []`` form -- and TOML reads a bare + key/value pair into whichever table the closest preceding header opened. + So the fallbacks of a scope have to be written at the top of that scope, + with a key relative to it, and that includes the ones sitting inside super + tables, which emit no header to separate them. + + Returns ``(dotted key, aot)`` pairs, keyed relative to the scope ``body`` + belongs to. + """ + found: list[tuple[str, AoT]] = [] + for k, v in body: + if k is None: + continue + path = f"{prefix}.{k.as_string()}" if prefix else k.as_string() + if _is_empty_aot(v): + found.append((path, v)) + elif isinstance(v, Table) and not _emits_header(k, v): + found.extend(_header_less_aots(v.value.body, path)) + return found + + +def _render_empty_aot(key: str, aot: AoT) -> str: + """Render an emptied array of tables as an inline empty array.""" + return ( + f"{aot.trivia.indent}" + f"{decode(key)}" + f" = []" + f"{aot.trivia.comment_ws}" + f"{decode(aot.trivia.comment)}" + f"{aot.trivia.trail or chr(10)}" + ) class Container(_CustomDict): # type: ignore[type-arg] @@ -674,10 +703,11 @@ def last_item(self) -> Item | None: def as_string(self) -> str: """Render as TOML string.""" s = "" - for k, v in _hoist_empty_aots(self._body): + for path, aot in _header_less_aots(self._body): + s += _render_empty_aot(path, aot) + for k, v in self._body: if k is not None: if _is_empty_aot(v): - s += self._render_aot(k, v) continue if isinstance(v, Table): if ( @@ -713,24 +743,8 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st if prefix is not None: _key = prefix + "." + _key - if ( - not table.is_super_table() - or ( - any( - not isinstance(v, (Table, AoT, Whitespace, Null)) - for _, v in table.value.body - ) - and not key.is_dotted() - ) - or ( - any( - k is not None and k.is_dotted() - for k, v in table.value.body - if isinstance(v, Table) - ) - and not key.is_dotted() - ) - ): + header_emitted = _emits_header(key, table) + if header_emitted: open_, close = "[", "]" if table.is_aot_element(): open_, close = "[[", "]]" @@ -751,9 +765,14 @@ 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 _hoist_empty_aots(table.value.body): + if header_emitted: + # A super table opens no scope, so its fallbacks belong to -- and + # were already written by -- the nearest enclosing header instead. + for path, aot in _header_less_aots(table.value.body): + cur += _render_empty_aot(path, aot) + for k, v in table.value.body: if k is not None and _is_empty_aot(v): - cur += self._render_aot(k, v) + continue elif isinstance(v, Table): if ( cur.strip(" ") @@ -787,24 +806,6 @@ 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: - 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.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 @@ -831,9 +832,11 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: f"{table.trivia.trail}" ) - for k, v in _hoist_empty_aots(table.value.body): + for path, aot in _header_less_aots(table.value.body): + cur += _render_empty_aot(path, aot) + for k, v in table.value.body: if k is not None and _is_empty_aot(v): - cur += self._render_aot(k, v) + continue elif isinstance(v, Table): assert k is not None if v.is_super_table():