Skip to content

Fix out-of-order dotted-key child promoted to a table swallowing following siblings - #588

Open
HarperZ9 wants to merge 3 commits into
python-poetry:masterfrom
HarperZ9:fix/out-of-order-dotted-child-header-556
Open

Fix out-of-order dotted-key child promoted to a table swallowing following siblings#588
HarperZ9 wants to merge 3 commits into
python-poetry:masterfrom
HarperZ9:fix/out-of-order-dotted-child-header-556

Conversation

@HarperZ9

@HarperZ9 HarperZ9 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #556.

The bug

Promoting a dotted-key child from an inline value to a table can leave its new [header] before keys that still render at the same scope. Re-parsing then captures those keys under the promoted table.

The failure is not limited to one out-of-order fragment. A super table can contribute both inline keys and later headers, two mixed super tables can interleave, and the same shape can occur inside a regular table or an array-of-tables element. Moving whole body entries therefore cannot preserve the TOML scope in every case. Leading comment trivia can also become detached from the key it documents.

The fix

Rendering now partitions every container recursively:

  • pure inline entries retain their established position before mixed super-table entries;
  • the inline contributions of mixed super tables render next, in stable order;
  • child table and array-of-tables headers render last, with their absolute enclosing prefix;
  • inline dotted keys remain relative to the currently active table header;
  • leading comments and whitespace move with the keyed entry they describe.

This handles root documents, nested tables, arrays of tables, multiple mixed super tables, and arbitrary dotted-key depth without special-casing an example. Headerless super tables retain their existing newline trivia, preserving established exact-output contracts.

Regression coverage

The tests cover:

  • the original out-of-order dotted-child promotion, including deeper variants;
  • a table added to a super table with a following scalar;
  • multiple mixed super tables in one document;
  • promotion inside a regular table with the full parent prefix;
  • promotion inside an array-of-tables element with the full parent prefix;
  • leading comment trivia that documents a moved inline sibling;
  • table, empty-table, and array-of-tables replacements.

Verification

  • python -m pytest -q: 1064 passed
  • python -m ruff check tomlkit/container.py tests/test_toml_document.py: passed
  • python -m ruff format --check tomlkit/container.py tests/test_toml_document.py: passed
  • python -m compileall -q tomlkit/container.py: passed
  • git diff --check: passed
  • byte-for-byte parse/render comparison across 262 valid toml-test fixtures: no mismatches
  • seven additional adversarial round trips covering quoted keys, multiple AoT elements, nested AoTs, and deeper dotted paths: passed

Relation to prior attempts

#558 and #575 were closed without merge after exposing narrower entry-reordering approaches. This PR operates at the rendering scope where relative inline keys and absolute child headers can be separated consistently.

@dimbleby

Copy link
Copy Markdown
Contributor

This fails to fix this modest variation on the original report:

#!/usr/bin/env python3

import tomlkit

doc = tomlkit.parse("a.b.c = 1\na.b.d = 2\na.e = 3\n")
doc["a"]["b"]["c"] = {"x": 9}

output = tomlkit.dumps(doc)

print(output)

still with output

[a.b.c]
x = 9
a.b.d = 2
a.e = 3

@HarperZ9

Copy link
Copy Markdown
Contributor Author

You're right, thanks. That case fails the same way on master, so it is pre-existing rather than a regression here, but it shows this fix is at the wrong layer to be complete.

The reposition I added only runs in OutOfOrderTableProxy.__setitem__, so it sees a single container. Your example promotes one level deeper: doc["a"]["b"]["c"] = ... goes through _replace_at on b's container, and the entries that get captured, a.b.d and a.e, live in other top-level a fragments. The inner container has no reference back to the document body where those siblings sit, so nothing moves them.

A complete fix has to renormalize the top-level body whenever a nested promotion turns a previously inline fragment into a header, at any depth. That means either giving out-of-order fragments a path to the root container, or normalizing out-of-order groups at render time so ordinary layout is left alone. Patching one more level would just push the counterexample down a level, which is what happened to #558 and #575.

I would rather not add a half-measure. If you are open to the root-level normalization approach I will take it in this PR; otherwise I am happy to close this and reopen once that is ready. Which do you prefer?

@HarperZ9
HarperZ9 force-pushed the fix/out-of-order-dotted-child-header-556 branch from 1181d18 to 3766cca Compare August 24, 2026 19:39
@HarperZ9

Copy link
Copy Markdown
Contributor Author

Reworked it. Your case is fixed now, at any dotting depth.

Instead of moving the promoted fragment in the proxy (which only saw one container), the document body is now rendered with inline entries before the header entries a mutation left after them. That is the order valid TOML always has at a single level: a [header] followed by a bare or dotted key that is not inside it cannot come from parsing, only from mutation, so normalising to it is safe. It is gated on self._out_of_order_keys and only moves entries actually trapped after a header, so every validly-parsed document renders byte-for-byte unchanged. Your doc["a"]["b"]["c"] = {...} case and a depth-4 version are now regression tests, and the full suite including the toml-test corpus is green (1059).

The diff is smaller than before, since this replaces the proxy-level reposition and the two helpers with one render step.

One boundary I want to be explicit about rather than have you find it: an out-of-order group nested inside a table header, e.g.

doc = tomlkit.parse("[outer]\np.b = 1\np.c = 2\n")
doc["outer"]["p"]["b"] = {"x": 9}

is still wrong, but for a different, pre-existing reason: the promoted table renders as [p.b] without the outer. prefix on master already, which is a display-name bug independent of ordering. I did not fold that into this PR, since fixing the header name is a separate change. Happy to look at it as a follow-up if you would like.

@dimbleby

Copy link
Copy Markdown
Contributor

still fails with this - which is even simpler than the original report

#!/usr/bin/env python3

import tomlkit


doc = tomlkit.loads("a.b = 1\nz = 2\n")
doc["a"]["new"] = {"x": 1}

output = tomlkit.dumps(doc)

print(output)

output

a.b = 1

[a.new]
x = 1
z = 2

@dimbleby

Copy link
Copy Markdown
Contributor

re your example which is "still wrong, but for a different, pre-existing reason". It is wrong for both reasons: the update captures too many keys and it loses the outer prefix.

I would expect that a correct fix for #556 should handle the captures-too-many-keys part.

@HarperZ9
HarperZ9 force-pushed the fix/out-of-order-dotted-child-header-556 branch from 3766cca to eeaa445 Compare August 24, 2026 19:57
@HarperZ9

Copy link
Copy Markdown
Contributor Author

Fixed, and generalised. That case is not out of order at all: a is a single super-table fragment that now renders a.b = 1 then [a.new], so my earlier gate on out-of-order tables skipped it.

I dropped the gate. The normalisation now keys on the actual violation, a keyed entry rendering inline after something that renders a header at the same level, rather than on any structural signal, so it covers all three shapes (out-of-order promotion, the nested form, and adding a table to a super table). A single cheap pass detects whether anything is trapped; a validly ordered document returns unchanged and renders byte-for-byte the same, so there is no layout change and no real cost for it (200 renders of a 500-table document stayed at roughly 2 ms each). Comments and whitespace carry no key, so they never count as trapped or move, which was the one regression the ungated version briefly caused and is now covered by keeping the check to keyed entries.

Your a.b = 1 / z = 2 case is a regression test and the full suite including the toml-test corpus is green (1060). Same boundary as before: an out-of-order group nested inside a table header still hits the separate, pre-existing [p.b]-missing-outer.-prefix bug, which I have kept out of this PR.

@dimbleby

Copy link
Copy Markdown
Contributor

per crossing update - the example you gave earlier is not only about the missing outer prefix, it also demonstrate an update putting a table in the wrong place so that it captures keys that it ought not to.

When a mutation makes a table render a `[header]` while a bare or dotted key
that is not inside it still follows at the same level, the header swallows that
key when the output is parsed again (python-poetry#556). This takes several shapes: assigning
a table over a child of an out-of-order dotted-key table (`doc["a"]["b"] = {...}`
over `a.b`/`a.c`), the nested `doc["a"]["b"]["c"] = ...` at any depth, adding a
table to a super table (`a.b = 1` then `doc["a"]["new"] = {...}`, leaving a
following `z = 2` after `[a.new]`), and the same shapes inside another table
(`[outer]` containing `p.b`/`p.c`).

A `[header]` followed at the same level by a bare or dotted key that is not
inside it cannot come from parsing, only from mutation. Both the document and
each rendered table now emit such trapped keys before the first header, which is
the order valid TOML always has at a single level. A single cheap pass detects
whether any keyed entry is trapped after a header; if not, the body renders
byte-for-byte unchanged, so validly ordered documents are untouched and pay only
one scan. Comments and whitespace carry no key and never move.

Fixes python-poetry#556

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@HarperZ9
HarperZ9 force-pushed the fix/out-of-order-dotted-child-header-556 branch from eeaa445 to ba3795f Compare August 24, 2026 20:02
@HarperZ9

Copy link
Copy Markdown
Contributor Author

You're right, and I was wrong to keep scoping that out. The captures-too-many-keys part belongs in this fix.

The normalisation only ran on the document top level; the [outer] case has its violation one container down, which renders through a different path. I've applied the same normalisation there too, so keys can no longer be captured at any nesting level. Your [outer] case now renders p.c = 2 before [p.b] instead of swallowing it, and the deeper p.b.c variant behaves the same. Full suite is green (1060) and render cost is unchanged (a 500-table document still dumps in about 3 ms).

The lost outer. prefix is the other half you pointed at. That one is a genuinely separate mechanism (the promoted table's display name drops the parent prefix, independent of ordering), so I left it out of this PR, but I am happy to take it too if you would rather this issue close both.

@dimbleby

Copy link
Copy Markdown
Contributor

please spend more time coming up with examples and testcases yourself - it is silly that I have to do this for you. Here are a couple more to get you started:

doc = tomlkit.loads("a.x=1\nq.x=2\nr=3\n")
doc["a"]["new"] = {"v": 1}
doc["q"]["new"] = {"v": 2}

(which makes quite the mess)

  doc = parse(                     
      """\                         
  [[p]]                            
  a.b = 1                          
  a.c = 2                          
  z = 3                            
  """                              
  )                                
                                   
  doc["p"][0]["a"]["b"] = {"x": 1} 

(which shows that you also need to take more care of Array-of-Table)

Ideally a fix should also take trivia into account - eg consider

   doc = parse(            
       """\                
   a.b = 1                 
   # Documentation for a.c 
   a.c = 2                 
   [z]                     
   q = 3                   
   """                     
   )                       
                           
   doc["a"]["b"] = {"x": 9}

@HarperZ9

Copy link
Copy Markdown
Contributor Author

Implemented the requested coverage in ecdea65 and replaced whole-entry reordering with recursive inline/header partitions.

The current head now preserves semantic round trips for all three examples:

  • multiple mixed super tables render the remaining inline keys before [a.new] / [q.new];
  • the AoT case retains its enclosing prefix as [p.a.b] and keeps a.c / z in the [[p]] element;
  • # Documentation for a.c moves with a.c before the promoted [a.b] header.

It also resolves the earlier nested-table half completely: the header is now [outer.p.b], not [p.b], while p.c remains under [outer].

Fresh verification on this head: 1,064 tests passed, Ruff and formatting clean, compilation and diff checks passed, and 262 valid toml-test fixtures remained byte-for-byte unchanged after parse/render. I also exercised quoted dotted keys, multiple AoT elements, nested AoTs, and deeper dotted paths as separate round trips.

@dimbleby

Copy link
Copy Markdown
Contributor
doc = tomlkit.loads("a.b=1\nz=2")  # nb no trailing newline
doc["a"]["new"] = {"x": 1}
z=2a.b=1

[a.new]
x = 1

@HarperZ9

Copy link
Copy Markdown
Contributor Author

Fixed in 5f95a0a.

The reordered inline partitions were still relying on their original trailing newlines. In the no-trailing-newline case, the final z=2 segment moved before a.b=1 without gaining a separator. The renderer now inserts a newline only when two reordered inline partitions would otherwise touch.

I added the exact regression for a.b=1\nz=2, including the semantic round trip. Fresh local verification on the pushed head: 1,065 tests passed; Ruff, format, compilation, and diff checks passed; all 262 valid toml-test fixtures remained byte-for-byte unchanged. Hosted checks are queued.

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.

updating a table captures too many keys

2 participants