Render an emptied array of tables as an empty array - #567
Conversation
_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.
davidpavlovschi
left a comment
There was a problem hiding this comment.
The empty fallback breaks nested arrays of tables because it keeps the full header prefix even though the fallback now renders as a key/value inside the current table scope. Minimal case: parse [t] followed by [[t.a]], pop the only t.a element, then serialize. This head emits [t] / t.a = [], which reparses as t.t.a instead of t.a. The same duplication occurs at deeper tables and inside an AoT element. Please add a nested regression that asserts doc.unwrap() equals parse(doc.as_string()).unwrap(). For an empty AoT rendered inside [t], the key/value fallback needs to be a = [] in that scope, not t.a = []. I ran the full suite on this head as well: 1,054 passed, 1 skipped; the current tests cover only root-level empty AoTs.
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.
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`.
|
Thanks — you were right, and the case was wider than the one you posted. Your minimal case (
The first is your bug in the other direction: dropping the prefix is only right when the parent actually emitted a So the fallbacks of a scope are now collected up front — walking through the super tables that emit no header of their own — and each is keyed relative to the scope that will read it. Verification on this head:
|
f767b1c to
658cca6
Compare
Summary
Addresses the array-of-tables half of #553.
When every element is removed from an array of tables, there is no
[[key]]header left to render, and_render_aotlooped over an empty body and emitted nothing — so the key vanished from the output entirely:This renders the inline
a = []form instead, which is what tomlkit already produces for an empty array built through the API (doc["a"] = []).The ordering part
Rendering
a = []in body order is not enough. TOML only reads bare key/value pairs before the first table header, so an emptied array of tables sitting after one would be read back as a key of that table:So empty arrays of tables are hoisted to just before the first table header. Non-empty ones are untouched and still render in place, byte for byte.
Scope: the other half of the issue
The issue also covers
doc["x"]["y"].pop("z")leaving an empty super table that renders as nothing. I have not fixed that here, because the current behaviour is pinned by two existing tests —test_delete_out_of_order_table_keyandtest_overwriting_out_of_order_tableboth assert that a super table emptied bydeldisappears from the output rather than rendering its own header.Making emptied super tables render
[x.y]breaks both. That looked like a deliberate call rather than an oversight, so I left it alone rather than rewriting your tests to suit. Happy to follow up if you'd like that changed — it is a small diff once the intended behaviour is settled.Tests
Four tests added, covering the bare case, the hoisting case, an emptied array preceded by a scalar, and a control asserting non-empty arrays of tables still round-trip unchanged.
Revert-verified: with
container.pyreverted, the first three fail and the control still passes.Full suite on Windows / Python 3.12: 1055 passed (1051 on a clean tree, plus these 4).
ruff checkandruff format --checkare clean on both changed files.