From dd4a5fe71efc6ad7c596bc6aef07cdb4f42b8991 Mon Sep 17 00:00:00 2001 From: Burt Matthews <80060660+earfman@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:29:47 -0700 Subject: [PATCH 1/3] Fix sub-table of a dotted key escaping its parent header path (#557) --- tomlkit/container.py | 53 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 8ff30d9..674e968 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -658,17 +658,35 @@ def as_string(self) -> str: return s - def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> str: + def _render_table( + self, + key: Key, + table: Table, + prefix: str | None = None, + header_prefix: str | None = None, + ) -> str: cur = "" if table.display_name is not None: _key = table.display_name + _hkey = _key else: _key = key.as_string() if prefix is not None: _key = prefix + "." + _key + # ``_hkey`` is the absolute key path used for the headers of nested + # sub-tables. It can diverge from ``_key`` when this table was reached + # through a dotted key: the table's own leaf items render relative to + # the nearest real header (so ``a.b`` stays under ``[t]``), but a + # genuine sub-table nested inside it still needs the full path + # (``[t.a.c]``, not ``[a.c]``). + if header_prefix is not None: + _hkey = header_prefix + "." + key.as_string() + else: + _hkey = _key + if ( not table.is_super_table() or ( @@ -718,12 +736,21 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st assert k is not None if v.is_super_table(): if k.is_dotted() and not key.is_dotted(): - # Dotted key inside table - cur += self._render_table(k, v) + # Outermost dotted key inside a table: leaf items render + # relative to this header, but a genuine sub-table nested + # inside still needs the absolute path (carried by + # ``header_prefix``). + cur += self._render_table(k, v, header_prefix=_hkey) else: - cur += self._render_table(k, v, prefix=_key) + # Super-table continuing a chain: keep leaves relative + # via ``_key`` while tracking the absolute header path. + cur += self._render_table( + k, v, prefix=_key, header_prefix=_hkey + ) else: - cur += self._render_table(k, v, prefix=_key) + cur += self._render_table( + k, v, prefix=_hkey, header_prefix=_hkey + ) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -732,7 +759,7 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st ): cur += "\n" assert k is not None - cur += self._render_aot(k, v, prefix=_key) + cur += self._render_aot(k, v, prefix=_hkey) else: cur += self._render_simple_item( k, v, prefix=_key if key.is_dotted() else None @@ -772,12 +799,18 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: assert k is not None if v.is_super_table(): if k.is_dotted(): - # Dotted key inside table - cur += self._render_table(k, v) + # Dotted key inside an array-of-tables element: leaf items + # render relative to the ``[[...]]`` header, but a genuine + # sub-table nested inside still needs the absolute path. + cur += self._render_table(k, v, header_prefix=_key) else: - cur += self._render_table(k, v, prefix=_key) + cur += self._render_table( + k, v, prefix=_key, header_prefix=_key + ) else: - cur += self._render_table(k, v, prefix=_key) + cur += self._render_table( + k, v, prefix=_key, header_prefix=_key + ) elif isinstance(v, AoT): assert k is not None cur += self._render_aot(k, v, prefix=_key) From 02958fc7fc2d7fe6f2e58e8a7d3fac1c11583ba8 Mon Sep 17 00:00:00 2001 From: Burt Matthews <80060660+earfman@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:31:15 -0700 Subject: [PATCH 2/3] Add regression tests for #557 (sub-table escaping parent header) --- tests/test_toml_document.py | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 7ba61f5..0b24166 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1666,3 +1666,50 @@ def test_scalar_is_not_captured_by_table_rendered_from_dotted_key() -> None: doc["z"] = 2 assert doc.as_string() == "a.b = 1\nz = 2\n" + + +def test_subtable_of_dotted_key_keeps_parent_header_path() -> None: + # https://github.com/python-poetry/tomlkit/issues/557 + # A dotted-key super table nested inside a real table (`[t]` -> `a.b = 1`) + # renders its leaf inline, but a sub-table added under it must keep the full + # header path (`[t.a.c]`), not escape to the document root as `[a.c]`. + doc = parse("[t]\na.b = 1\n") + doc["t"]["a"]["c"] = {} + + expected = """\ +[t] +a.b = 1 + +[t.a.c] +""" + + assert doc.as_string() == expected + assert parse(doc.as_string()) == {"t": {"a": {"b": 1, "c": {}}}} + # Idempotent: re-rendering the parsed result must not drift. + assert parse(doc.as_string()).as_string() == expected + + # Chained dotted parent keeps leaves relative while the sub-table is absolute. + doc = parse("[t]\na.b.x = 1\n") + doc["t"]["a"]["b"]["c"] = {} + + assert doc.as_string() == "[t]\na.b.x = 1\n\n[t.a.b.c]\n" + assert parse(doc.as_string()) == {"t": {"a": {"b": {"x": 1, "c": {}}}}} + + +def test_subtable_of_dotted_key_in_aot_keeps_parent_header_path() -> None: + # https://github.com/python-poetry/tomlkit/issues/557 + # The same rule applies inside an array-of-tables element: a sub-table added + # under a dotted key must render as `[servers.a.c]`, not leak to root `[a.c]`. + doc = parse("[[servers]]\na.b = 1\n") + doc["servers"][0]["a"]["c"] = {"x": 9} + + expected = """\ +[[servers]] +a.b = 1 + +[servers.a.c] +x = 9 +""" + + assert doc.as_string() == expected + assert parse(doc.as_string()) == {"servers": [{"a": {"b": 1, "c": {"x": 9}}}]} From 84e796c9244ce3068d6ad27d625ace3d34102807 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:32:42 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tomlkit/container.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tomlkit/container.py b/tomlkit/container.py index 674e968..d64e48c 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -748,9 +748,7 @@ def _render_table( k, v, prefix=_key, header_prefix=_hkey ) else: - cur += self._render_table( - k, v, prefix=_hkey, header_prefix=_hkey - ) + cur += self._render_table(k, v, prefix=_hkey, header_prefix=_hkey) elif isinstance(v, AoT): if ( cur.strip(" ") @@ -804,13 +802,9 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: # sub-table nested inside still needs the absolute path. cur += self._render_table(k, v, header_prefix=_key) else: - cur += self._render_table( - k, v, prefix=_key, header_prefix=_key - ) + cur += self._render_table(k, v, prefix=_key, header_prefix=_key) else: - cur += self._render_table( - k, v, prefix=_key, header_prefix=_key - ) + cur += self._render_table(k, v, prefix=_key, header_prefix=_key) elif isinstance(v, AoT): assert k is not None cur += self._render_aot(k, v, prefix=_key)