From ea1a82feedf0e6877074d70ea762f4811d61558b Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:48:14 +0800 Subject: [PATCH] Render a dotted key's sub-table inside an inline table as an inline table A dotted key inside an inline table is materialized as a Table whose key is flagged as dotted, and InlineTable._render_dotted rendered every Table child with the `prefix.child = body` shape. That is right for a *dotted* child, but a child added afterwards is a real sub-table, and its body is not a value: >>> doc = parse("a = {b.c = 1}\n") >>> doc["a"]["b"]["e"] = {"f": 3} >>> dumps(doc) 'a = {b.c = 1, a.a. = q = 1}' # a.a. is a key, then a stray '.' `tomllib` rejects that (`Invalid initial character for a key part`), and so does tomlkit's own parser, so the edit produced a document that could not be read back. The parser cannot produce this state - it takes a programmatic edit - which is why no round-trip test covered it. Only recurse for a child that is itself dotted, and render a real sub-table as an inline table, converting nested tables the same way. --- tests/test_items.py | 21 +++++++++++++++++++++ tomlkit/items.py | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/tests/test_items.py b/tests/test_items.py index d7c06fbf..84b0ac4d 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -1079,6 +1079,27 @@ def test_adding_to_nested_dotted_key_inside_inline_table() -> None: assert parse(rendered).as_string() == rendered +def test_adding_a_sub_table_to_a_dotted_key_inside_inline_table() -> None: + doc = parse("a = {b.c = 1, d = 2}\n") + doc["a"]["b"]["e"] = {"f": 3} + + # A sub-table of a dotted key can only be expressed inside an inline table + # as an inline table; rendering it as its own ``b.e. = f = 3`` pair is not + # valid TOML. The added value must survive the round-trip. + rendered = doc.as_string() + assert parse(rendered) == {"a": {"b": {"c": 1, "e": {"f": 3}}, "d": 2}} + assert parse(rendered).as_string() == rendered + + +def test_adding_a_nested_sub_table_to_a_dotted_key_inside_inline_table() -> None: + doc = parse("a = {b.c = 1}\n") + doc["a"]["b"]["d"] = {"e": {"f": 3}} + + rendered = doc.as_string() + assert parse(rendered) == {"a": {"b": {"c": 1, "d": {"e": {"f": 3}}}}} + assert parse(rendered).as_string() == rendered + + def test_appending_to_comma_first_array_does_not_double_separator() -> None: doc = parse( """\ diff --git a/tomlkit/items.py b/tomlkit/items.py index c950e5d0..86709dfd 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -2156,14 +2156,30 @@ def as_string(self) -> str: def _render_dotted(self, key: Key, table: Table) -> list[str]: """Render a table materialized from a dotted key as a list of ``prefix.child = value`` strings, recursing into nested dotted - children.""" + children. A child that is a real sub-table (its own key is not dotted) + is rendered as an inline table instead, since ``prefix.child = body`` + is not a valid value.""" prefix = f"{key.as_string()}.{key.sep}" - parts = [] + parts: list[str] = [] for k, v in table.value.body: if k is None: continue - if isinstance(v, Table): + if isinstance(v, Table) and k.is_dotted(): + # Only a *dotted* child keeps the ``prefix.child`` shape; a child + # whose own key is not dotted is a real sub-table, which has no + # ``prefix.child = value`` form and must be rendered inline. parts.extend(f"{prefix}{sub}" for sub in self._render_dotted(k, v)) + elif isinstance(v, Table): + # A real sub-table nested under a dotted key can only be + # expressed inside an inline table as an inline table; rendering + # it as ``prefix.child = `` would emit invalid TOML such as + # ``a.e. = f = 3``. Sub-tables nest arbitrarily deep, so convert + # the whole subtree. + value = InlineTable(_as_inline_container(v), v.trivia, new=True) + parts.append( + f"{prefix}{k.as_string()}{k.sep}{value.as_string()}" + f"{v.trivia.comment_ws}{v.trivia.comment}" + ) else: trail = v.trivia.trail.replace("\n", "") parts.append( @@ -2363,6 +2379,23 @@ def _getstate(self, protocol: int = 3) -> tuple[list[Table], str | None, bool]: return self._body, self.name, self._parsed +def _as_inline_container(table: Table) -> container.Container: + """Return a copy of ``table``'s container with every nested table converted + so that it renders as an inline table. + + Used to render a sub-table of a dotted key inside an inline table, where + the only valid representation of a nested table is another inline table. + """ + from tomlkit.container import Container + + result = Container() + for k, v in table.value.body: + if k is not None and isinstance(v, Table): + v = InlineTable(_as_inline_container(v), v.trivia, new=True) + result.append(k, v) + return result + + class Null(Item): """ A null item.