diff --git a/CHANGELOG.md b/CHANGELOG.md index 26584a1..92803ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - 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)) - Fix the separator comma being swallowed by a trailing comment when appending a key to a multiline inline table, leaving the new key without a separator so the result no longer round-trips. ([#512](https://github.com/python-poetry/tomlkit/issues/512)) - Fix a `KeyAlreadyPresent` error when parsing or accessing an out-of-order table whose array-of-tables elements are split across the table's parts. ([#505](https://github.com/python-poetry/tomlkit/issues/505)) +- Fix a `KeyAlreadyPresent` error when a dotted-key header extension (e.g. `[fruit.apple.texture]`) appears after an unrelated table following an array-of-tables (e.g. `[[fruit]]`). ([#261](https://github.com/python-poetry/tomlkit/issues/261)) ## [0.15.0] - 2026-05-10 diff --git a/README.md b/README.md index 0baf4f8..e2decfc 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,15 @@ Part of the implementation has been adapted, improved and fixed from [Molten](ht See the [documentation](https://tomlkit.readthedocs.io/) for more information. +## Limitations + +`tomlkit` preserves the layout of parsed documents where it can. One known +exception is an out-of-order sub-table that extends the last element of an array +of tables, such as `[fruit.apple.texture]` appearing after an unrelated table +following `[[fruit]]`. When the document is serialized again, that sub-table is +emitted inside the array element it belongs to. Its data is preserved, but its +physical position in the file is normalized. + ## Installation If you are using [uv](https://docs.astral.sh/uv), you can diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 1cdfb9c..a4edef5 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -667,6 +667,69 @@ def test_out_of_order_table_merges_three_aot_fragments() -> None: assert hooks["state"]["z"] == 3 +def test_aot_extension_via_dotted_key_after_unrelated_table() -> None: + content = """\ +[[fruit]] +apple.color = "red" + +[potato] + +[fruit.apple.texture] +smooth = true +""" + doc = parse(content) + fruit = doc["fruit"] + assert len(fruit) == 1 + assert fruit[0]["apple"]["color"] == "red" + assert fruit[0]["apple"]["texture"]["smooth"] is True + assert "potato" in doc + # Round-trip + assert parse(doc.as_string())["fruit"][0]["apple"]["texture"]["smooth"] is True + + +def test_aot_extension_deep_merges_existing_nested_table() -> None: + content = """\ +[[fruit]] +apple.texture.rough = false + +[potato] + +[fruit.apple.texture] +smooth = true +""" + texture = parse(content)["fruit"][0]["apple"]["texture"] + assert texture["rough"] is False + assert texture["smooth"] is True + + +@pytest.mark.parametrize( + "content", + [ + """\ +[[fruit]] +apple.texture = "rough" + +[potato] + +[fruit.apple.texture] +smooth = true +""", + """\ +[[fruit]] +apple.texture.smooth = false + +[potato] + +[fruit.apple.texture.smooth] +value = true +""", + ], +) +def test_aot_extension_keeps_redefinition_errors(content: str) -> None: + with pytest.raises(tomlkit.exceptions.ParseError, match=r'Key ".*" already exists'): + parse(content) + + def test_out_of_order_tables_are_still_dicts() -> None: content = """ [a.a] diff --git a/tomlkit/container.py b/tomlkit/container.py index 75e0901..3a582aa 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -260,6 +260,37 @@ def append( return self elif isinstance(current, AoT): if not item.is_aot_element(): + if item.is_super_table(): + # A dotted-key header like [fruit.apple.texture] + # appearing after an unrelated table extends the last + # element of the AoT (TOML spec: the [[fruit]] + # header preceding the dotted key is implied). + # Walk the super-table and merge into the last + # element, bypassing the normal append path which + # would trip over raw_append-created keys (they lack + # the _dotted flag that _handle_dotted_key sets). + def _deep_merge_into( + target: Container, source: Table + ) -> None: + for sk, sv in source.value.body: + if sk is None: + continue + if sk in target: + existing = target.item(sk) + if isinstance(existing, Table) and isinstance( + sv, Table + ): + _deep_merge_into( + existing.value, + sv, # type: ignore[arg-type] + ) + else: + raise KeyAlreadyPresent(sk) + else: + target._raw_append(sk, sv) + + _deep_merge_into(current[-1].value, item) + return self # Tried to define a table after an AoT with the same name. raise KeyAlreadyPresent(key)