Skip to content

Render each run of a split array of tables where it was written - #601

Open
hxperl wants to merge 1 commit into
python-poetry:masterfrom
hxperl:fix-split-aot-render-order
Open

Render each run of a split array of tables where it was written#601
hxperl wants to merge 1 commit into
python-poetry:masterfrom
hxperl:fix-split-aot-render-order

Conversation

@hxperl

@hxperl hxperl commented Sep 11, 2026

Copy link
Copy Markdown

Summary

tomlkit.dumps(tomlkit.parse(s)) does not return s when a document interrupts an array of tables with an unrelated table and then continues it. The intervening table is moved below the whole array.

I did not find an existing issue for this, so there is no issue number to reference.

Reproduction

import tomlkit

content = """\
[[fruit]]
name = "apple"

[settings]
color = true

[[fruit]]
name = "banana"
"""

print(tomlkit.dumps(tomlkit.parse(content)))

On master (4b38bec):

[[fruit]]
name = "apple"

[[fruit]]
name = "banana"
[settings]
color = true

[settings] has moved, and the blank line before it is gone. Nothing was modified between the parse and the dump. The document is valid TOML 1.0.0 — a table header may interrupt an array of tables and a later [[fruit]] continues the same array — and the parse is correct: doc["fruit"] is the two-element array. Only the rendering is wrong.

With this branch the input is reproduced byte for byte.

Cause

Parser._parse_aot gathers only contiguous [[fruit]] headers, so the second run reaches Container.append as a separate AoT under a key that is already present. That branch merged the new elements into the existing AoT and returned:

elif isinstance(item, AoT):
    ...
    for table in item.body:
        current.append(table)

    return self

Once merged, nothing recorded that a run had been written further down, and as_string rendered the entire array at the first run's body position.

Approach

The elements stay in one AoT, so doc["fruit"] is still the whole array and every existing accessor, unwrap() and mutation path is unchanged. What is added is a marker in the container body, _AoTContinuation, recording that a later run was written at that point and which elements it began with. as_string renders the AoT up to the first marker, and each marker renders its own slice in place.

The alternative I tried first was the one the out-of-order table path uses: give the run its own body entry, make _map[key] a tuple, and merge the runs on access. It fixes the rendering just as well, but doc["fruit"] then has to be a merged copy, and doc["fruit"].append(table) silently does nothing. That seemed worse than the bug, so I went with the marker. If you would rather have the split represented in _map — in line with how out-of-order tables work — I am happy to redo it that way; it would want an AoT-shaped proxy to keep mutation working.

Behaviour of the marker after parsing:

  • elements appended later go to the end of the array, so they render with the last run;
  • deleting the element a run started at moves the run to its next surviving element;
  • deleting every element of a run makes it render nothing.

There are tests for the first and second of these.

On the changed test

test_parse_aot_without_ending_newline asserted the reordered output. It came from #422 / #381, which was about a missing final newline corrupting the dump, and the expectation simply captured what the renderer did at the time. It now asserts that the document is preserved; the doc == {...} half of the test is unchanged and still passes.

What I verified

  • Full suite on macOS arm64, Python 3.14.4: 1062 passed. CI will need to confirm the other versions and Linux.
  • ruff check and mypy report exactly the same pre-existing findings before and after the change (3 and 97 respectively, none in the lines touched).
  • Round-trip fuzzing over ~40k generated documents (key/value styles, comments, whitespace, [t], [t.sub] and [[arr]] headers): before, 464 documents did not round-trip and every one was this bug; after, none.
  • copy.deepcopy and pickle round-trips of a split array (the marker needed a _getstate, and Container.__setstate__ recomputes the flag).

Rendering does one extra pass over the body to locate markers. It is guarded by a flag set only when a split array is actually parsed, so documents without one — nearly all of them — are untouched.

Adjacent, not fixed here

The same fuzzing shows a sub-table of an array element is still pulled up out of order:

[[arr]]
[t2]
[arr.sub]

dumps as [[arr]] / [arr.sub] / [t2]. That is a different code path (the is_super_table() branch just above the one changed here), so I left it alone rather than widen this PR. Happy to open an issue for it.

Agent Drafting Metadata

  • Agent: Claude Code
  • Model: Claude Opus 5
  • Notes: The bug was found by property-fuzzing dumps(parse(s)) == s, not from an issue report. Diagnosis, the choice between the two approaches, the patch and the tests were drafted with the agent and reviewed and run locally by me; all output quoted above is from real runs on this branch.

A document may interrupt an array of tables with an unrelated table and
then continue it:

    [[fruit]]
    name = "apple"

    [settings]
    color = true

    [[fruit]]
    name = "banana"

The parser collects only contiguous `[[fruit]]` headers into one AoT, so
the second run arrives at `Container.append` as a separate AoT under a
key that already exists. That branch merged its elements into the first
AoT and returned, discarding where the run appeared, and rendering then
emitted the whole array at the first run's position — moving `[settings]`
below `[[fruit]] name = "banana"` on a plain parse/dump round-trip.

Keep the elements in the single AoT, so `doc["fruit"]` is still the whole
array, and record the later run with an `_AoTContinuation` marker in the
body. Rendering splits the AoT across its runs at the recorded positions.
Elements appended after parsing render with the last run, and a run whose
elements have all been deleted renders nothing.

test_parse_aot_without_ending_newline came from python-poetry#422 (a missing final
newline corrupting the dump) and pinned the reordered output of the day
as its expectation; it now asserts the document is preserved, which is
what that fix was after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyaZ48KuBpkPpfPui9xCUK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant