From 131b036962433944984695d5dc769969fd0e08b9 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:55:10 +0800 Subject: [PATCH] fix: reject a table or array of tables inside an array `Array.as_string` joins its children's `as_string()` with bare concatenation and has no notion of a child that renders as a `[header]` block, so a `Table` or an `AoT` placed in an array kept its value in `unwrap()` and lost its header in the text. The result does not parse back. `InlineTable._validate_child` already rejected a `Table` (11e22ae, #532). `Array` had no check at all, and the inline-table guard did not cover `AoT`, so the rest of the family wrote TOML nobody can read back. Add `Array._validate_child` to every mutation entry point and the missing `AoT` branch to the existing one. Behaviour change: these conversions now raise instead of silently producing a truncated document. --- tests/test_items.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ tomlkit/items.py | 15 ++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tests/test_items.py b/tests/test_items.py index d7c06fbf..d76e0d26 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -575,6 +575,40 @@ def test_array_add_line_invalid_value() -> None: assert len(t) == 0 +def test_table_and_aot_in_array_are_rejected() -> None: + # A table renders as its own ``[header]`` block, so accepting one in an + # array silently drops the header on rendering while ``unwrap()`` still + # reports the element: the array looks fine and the text is not valid TOML. + table = api.table() + table.append("a", 1) + aot = parse("[[a]]\nx = 1\n\n[[a]]\nx = 2\n")["a"] + + t = api.array() + t.append(1) + + with pytest.raises(ValueError, match="cannot contain a table"): + t.append(table) + with pytest.raises(ValueError, match="cannot contain a table"): + t.insert(0, table) + with pytest.raises(ValueError, match="cannot contain a table"): + t[0] = table + with pytest.raises(ValueError, match="cannot contain a table"): + t.add_line(table) + + with pytest.raises(ValueError, match="cannot contain an array of tables"): + t.append(aot) + with pytest.raises(ValueError, match="cannot contain an array of tables"): + t.insert(0, aot) + with pytest.raises(ValueError, match="cannot contain an array of tables"): + t[0] = aot + with pytest.raises(ValueError, match="cannot contain an array of tables"): + t.add_line(aot) + + # None of the rejected conversions may have touched the array. + assert t.as_string() == "[1]" + assert t.unwrap() == [1] + + def test_dicts_are_converted_to_tables_and_keep_order() -> None: t = item( { @@ -964,6 +998,22 @@ def test_append_table_to_inline_table_raises() -> None: inline_table["table"] = table +def test_append_aot_to_inline_table_raises() -> None: + # An array of tables renders as its own ``[[header]]`` blocks, so it needs + # the same rejection as a bare table: accepting it drops the headers on + # rendering while ``unwrap()`` still reports the tables. + aot = parse("[[a]]\nx = 1\n\n[[a]]\nx = 2\n")["a"] + inline_table = api.inline_table() + + with pytest.raises(ValueError, match="cannot contain an array of tables"): + inline_table.append("aot", aot) + with pytest.raises(ValueError, match="cannot contain an array of tables"): + inline_table["aot"] = aot + + assert inline_table.as_string() == "{}" + assert inline_table.unwrap() == {} + + def test_deleting_inline_table_element_does_not_leave_trailing_separator() -> None: table = api.inline_table() table["foo"] = "bar" diff --git a/tomlkit/items.py b/tomlkit/items.py index c950e5d0..e48d0c73 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -1526,6 +1526,7 @@ def add_line( it = item(el, _parent=self) if isinstance(it, Comment) or (add_comma and isinstance(el, Whitespace)): raise ValueError(f"item type {type(it)} is not allowed in add_line") + self._validate_child(it) if not isinstance(it, Whitespace): if whitespace: new_values.append(Whitespace(whitespace)) @@ -1583,10 +1584,21 @@ def item(self, index: int) -> Item: def __getitem__(self, key: int | slice) -> Any: # type: ignore[override] return list.__getitem__(self, key) + def _validate_child(self, value: Any) -> None: + # A table or an array of tables renders as its own ``[header]`` block, + # so it cannot be rendered inside the brackets of an array. Accepting + # one would drop the header on rendering -- the element is still + # reported by ``unwrap()``, but the text is no longer valid TOML. + if isinstance(value, Table): + raise ValueError("Arrays cannot contain a table") + if isinstance(value, AoT): + raise ValueError("Arrays cannot contain an array of tables") + def __setitem__(self, key: int | slice, value: Any) -> None: # type: ignore[override] if isinstance(key, slice): raise ValueError("slice assignment is not supported") it = item(value, _parent=self) + self._validate_child(it) list.__setitem__(self, key, it) if key < 0: key += len(self) @@ -1594,6 +1606,7 @@ def __setitem__(self, key: int | slice, value: Any) -> None: # type: ignore[ove def insert(self, pos: int, value: Any) -> None: # type: ignore[override] it = item(value, _parent=self) + self._validate_child(it) length = len(self) if not isinstance(it, (Comment, Whitespace)): list.insert(self, pos, it) @@ -2051,6 +2064,8 @@ def append(self, key: Key | str | None, _item: Any) -> InlineTable: def _validate_child(self, _item: Item) -> None: if isinstance(_item, Table): raise ValueError("Inline tables cannot contain a table") + if isinstance(_item, AoT): + raise ValueError("Inline tables cannot contain an array of tables") def as_string(self) -> str: buf = "{"