From 77fc81f152577cdad701ba3034ddaf3d3e3f6f14 Mon Sep 17 00:00:00 2001 From: Labib-Bin-Salam Date: Thu, 18 Jun 2026 11:23:48 +0100 Subject: [PATCH] Fix duplicated prefix and captured sibling when replacing a dotted key with a table Replacing a dotted key's value with a table (e.g. ``doc["a"] = {...}`` where ``a`` was parsed from ``a.b = ...``) kept the dotted key, which both duplicated the prefix onto the new ``[a]`` header (#524) and let the header swallow any sibling that follows it on round-trip (#513). A dotted key only renders correctly alongside a super table; when the replacement is a non-super table it now drops the dotted key, and when an inline sibling still follows it moves past it -- mirroring the existing value-to-table handling. That scan now skips dotted-key tables when looking for the first real header, which additionally fixes the same capture for a plain value replaced by a table before a dotted key. Fixes #513 Fixes #524 --- CHANGELOG.md | 1 + tests/test_toml_document.py | 69 +++++++++++++++++++++++++++++++++++++ tomlkit/container.py | 35 +++++++++++++++++-- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26584a1c..65b0c8ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Fix invalid serialization with a duplicated comma when appending or inserting into a comma-first formatted array. ([#499](https://github.com/python-poetry/tomlkit/pull/499)) - Fix unparseable serialization when adding a key to a dotted-key table inside an inline table. ([#500](https://github.com/python-poetry/tomlkit/pull/500)) - Fix a table replaced by a plain value being serialized inside the preceding table's body when other tables follow; the value now moves before the first table like other root-level values. ([#504](https://github.com/python-poetry/tomlkit/issues/504)) +- Fix assigning a table over a dotted key (e.g. `doc["a"] = {...}` where `a` came from `a.b = ...`): the dotted prefix was duplicated onto the new `[a]` header, and the header then swallowed any sibling that follows it on round-trip. The replacement now renders as a plain table and, when needed, moves before the inline entries (values and dotted keys) it would otherwise capture. ([#513](https://github.com/python-poetry/tomlkit/issues/513), [#524](https://github.com/python-poetry/tomlkit/issues/524)) - Restore `dumps()` rendering mapping-like wrappers around a parsed document (e.g. `dotty_dict`'s `Dotty`) through their delegated `as_string`, preserving the original table order and layout instead of re-encoding through a plain dict — a 0.15.0 regression. ([#482](https://github.com/python-poetry/tomlkit/issues/482)) - Fix uncontrolled recursion when parsing deeply nested documents: crafted input could crash the process with a `RecursionError`. Values nested more than 100 levels deep and keys with more than 100 dotted fragments now raise `ParseError`. ([#459](https://github.com/python-poetry/tomlkit/issues/459)) - Fix `comment()` producing invalid TOML for a multiline string by prefixing every line with `#`, not just the first. ([#449](https://github.com/python-poetry/tomlkit/issues/449)) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 1cdfb9c5..43bef612 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1021,6 +1021,75 @@ def test_replace_with_aot_of_nested() -> None: assert doc.as_string().strip() == dedent(expected).strip() +def test_replace_dotted_key_with_table() -> None: + # https://github.com/python-poetry/tomlkit/issues/524 + content = "fruit.apple = true\n" + doc = parse(content) + doc["fruit"] = {"a": 1} + # The dotted prefix must be dropped instead of duplicated onto the header. + assert ( + doc.as_string() + == """[fruit] +a = 1 +""" + ) + assert parse(doc.as_string())["fruit"] == {"a": 1} + + +def test_replace_dotted_key_with_empty_table_keeps_following_sibling() -> None: + # https://github.com/python-poetry/tomlkit/issues/513 + content = """a.b = 1 +c.d = 2 +""" + doc = parse(content) + doc["a"] = {} + # ``[a]`` must not swallow the following ``c.d`` dotted key. + assert ( + doc.as_string() + == """c.d = 2 + +[a] +""" + ) + assert parse(doc.as_string()) == {"c": {"d": 2}, "a": {}} + + +def test_replace_dotted_key_with_table_keeps_following_sibling() -> None: + # https://github.com/python-poetry/tomlkit/issues/513 + content = """a.b = 1 +c.d = 2 +""" + doc = parse(content) + doc["a"] = {"x": 9} + assert ( + doc.as_string() + == """c.d = 2 + +[a] +x = 9 +""" + ) + assert parse(doc.as_string()) == {"c": {"d": 2}, "a": {"x": 9}} + + +def test_replace_value_with_table_keeps_following_dotted_sibling() -> None: + # A plain value turning into a table must likewise clear the inline region + # (including dotted keys) before emitting its header. + content = """x = 1 +c.d = 2 +""" + doc = parse(content) + doc["x"] = {} + assert ( + doc.as_string() + == """c.d = 2 + +[x] +""" + ) + assert parse(doc.as_string()) == {"c": {"d": 2}, "x": {}} + + def test_replace_with_comment() -> None: content = 'a = "1"' doc = parse(content) diff --git a/tomlkit/container.py b/tomlkit/container.py index 75e09018..41fbcbd1 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -821,10 +821,30 @@ def _replace_at( k, v = self._body[idx] assert k is not None + # A dotted key renders its value inline (e.g. ``a.b = 1``), which is only + # consistent with a super table. When the replacement value renders with + # its own ``[header]`` instead (a non-super table), keeping the dotted key + # duplicates the prefix onto the header (#524). Drop the dotted key so the + # replacement renders as a plain table. + dotted_to_header = ( + k.is_dotted() and isinstance(value, Table) and not value.is_super_table() + ) + # That new header also captures every sibling that renders inline -- plain + # values and dotted keys -- if any still follow it (#513), so it must be + # moved past them, exactly as a value-to-table change already is. + reposition_dotted = dotted_to_header and any( + not isinstance(cur_val, (Null, Whitespace)) + and not ( + isinstance(cur_val, (Table, AoT)) + and (cur_key is None or not cur_key.is_dotted()) + ) + for cur_key, cur_val in self._body[idx + 1 :] + ) if not isinstance(new_key, Key): if ( isinstance(value, (AoT, Table)) != isinstance(v, (AoT, Table)) or new_key != k.key + or dotted_to_header ): new_key = SingleKey(new_key) else: # Inherit the sep of the old key @@ -835,12 +855,21 @@ def _replace_at( if new_key != k: dict.__delitem__(self, k.key) - if isinstance(value, (AoT, Table)) != isinstance(v, (AoT, Table)): + if ( + isinstance(value, (AoT, Table)) != isinstance(v, (AoT, Table)) + or reposition_dotted + ): self.remove(k) if isinstance(value, (AoT, Table)): - # new tables should appear after all non-table values + # New tables must appear after all entries that render inline: + # plain values and dotted keys (which are super tables). Skip + # those and insert before the first real ``[header]`` so the new + # table cannot swallow a following sibling on round-trip. for i in range(idx, len(self._body)): - if isinstance(self._body[i][1], (AoT, Table)): + cur_key, cur_val = self._body[i] + if isinstance(cur_val, (AoT, Table)) and not ( + cur_key is not None and cur_key.is_dotted() + ): self._insert_at(i, new_key, value) idx = i break