diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 7ba61f5..cb0af6f 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1666,3 +1666,71 @@ 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_emptied_array_of_tables_renders_as_empty_array() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # An array of tables with no elements left has no `[[key]]` header to + # render, so it must fall back to the inline `key = []` form. Rendering + # nothing dropped the key entirely. + doc = parse("[[a]]\nx = 1\n") + doc["a"].pop() + + assert doc.as_string() == "a = []\n" + assert parse(doc.as_string()) == {"a": []} + + +def test_emptied_array_of_tables_is_hoisted_above_table_headers() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # TOML only reads bare key/value pairs before the first table header, so + # the inline fallback has to be emitted there rather than in body order. + # Left in place it would be parsed back as a key of the preceding table. + doc = parse("[t]\nq = 2\n\n[[a]]\nx = 1\n") + doc["a"].pop() + + assert parse(doc.as_string()) == {"t": {"q": 2}, "a": []} + assert doc.as_string().index("a = []") < doc.as_string().index("[t]") + + +def test_emptied_array_of_tables_keeps_preceding_scalars() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + doc = parse("v = 9\n\n[[a]]\nx = 1\n") + doc["a"].pop() + + assert parse(doc.as_string()) == {"v": 9, "a": []} + + +def test_non_empty_array_of_tables_is_not_hoisted() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # Only emptied arrays of tables change form; ordinary ones must round-trip + # byte for byte. + content = "[t]\nq = 2\n\n[[a]]\nx = 1\n" + + assert parse(content).as_string() == content + + +@pytest.mark.parametrize( + ("content", "empty"), + [ + # Nested directly under a table. + ("[t]\nq = 2\n\n[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # Nested under a table that also has a sibling sub-table after it. + ("[t]\n\n[[t.a]]\nx = 1\n\n[t.b]\ny = 3\n", lambda doc: doc["t"]["a"]), + # ... and before it, so the fallback has to move. + ("[t]\n\n[t.b]\ny = 3\n\n[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # Two levels down. + ("[t]\n\n[t.u]\n\n[[t.u.a]]\nx = 1\n", lambda doc: doc["t"]["u"]["a"]), + # Inside an element of another array of tables. + ("[[e]]\nn = 1\n\n[[e.a]]\nx = 1\n", lambda doc: doc["e"][0]["a"]), + ], +) +def test_emptied_nested_array_of_tables_round_trips(content, empty) -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # The inline fallback is a bare key/value pair emitted inside the scope its + # header named, so it must be written with the bare key. Carrying the + # header prefix over emitted `t.a = []` under `[t]`, which reads back as + # `t.t.a`. + doc = parse(content) + empty(doc).pop() + + assert parse(doc.as_string()).unwrap() == doc.unwrap() diff --git a/tomlkit/container.py b/tomlkit/container.py index 8ff30d9..32cadec 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -32,6 +32,47 @@ _NOT_SET = object() +def _is_empty_aot(item: Item) -> bool: + """Whether ``item`` is an array of tables with no elements left.""" + return isinstance(item, AoT) and not item.body + + +def _hoist_empty_aots( + body: list[tuple[Key | None, Item]], +) -> list[tuple[Key | None, Item]]: + """Order a table body so emptied arrays of tables render before any header. + + An emptied array of tables has no ``[[key]]`` header left to render, so it + falls back to the inline ``key = []`` form. TOML reads a bare key/value pair + into whichever table the closest preceding header opened, so one left in + body order after a header would be read back as a key of *that* table + instead of of the table it belongs to. + """ + first_header = next( + ( + i + for i, (_, v) in enumerate(body) + if isinstance(v, (Table, AoT)) and not _is_empty_aot(v) + ), + None, + ) + if first_header is None: + return body + + hoisted = [ + (k, v) for k, v in body[first_header:] if k is not None and _is_empty_aot(v) + ] + if not hoisted: + return body + + rest = [ + (k, v) + for k, v in body[first_header:] + if not (k is not None and _is_empty_aot(v)) + ] + return body[:first_header] + hoisted + rest + + class Container(_CustomDict): # type: ignore[type-arg] """ A container for items within a TOMLDocument. @@ -633,8 +674,11 @@ def last_item(self) -> Item | None: def as_string(self) -> str: """Render as TOML string.""" s = "" - for k, v in self._body: + for k, v in _hoist_empty_aots(self._body): if k is not None: + if _is_empty_aot(v): + s += self._render_aot(k, v) + continue if isinstance(v, Table): if ( s.strip(" ") @@ -707,8 +751,10 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st elif table.trivia.indent == "\n": cur += table.trivia.indent - for k, v in table.value.body: - if isinstance(v, Table): + for k, v in _hoist_empty_aots(table.value.body): + if k is not None and _is_empty_aot(v): + cur += self._render_aot(k, v) + elif isinstance(v, Table): if ( cur.strip(" ") and not cur.strip(" ").endswith("\n") @@ -741,6 +787,24 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st return cur def _render_aot(self, key: Key, aot: AoT, prefix: str | None = None) -> str: + if not aot.body: + # An array of tables with no elements has no ``[[key]]`` header to + # render, so fall back to the inline empty-array form. Rendering + # nothing would drop the key entirely. + # + # ``prefix`` is deliberately not applied: the fallback is a bare + # key/value pair emitted inside the scope the prefix names, so + # repeating the prefix would nest the key under itself. + trail = aot.trivia.trail or "\n" + return ( + f"{aot.trivia.indent}" + f"{decode(key.as_string())}" + f" = []" + f"{aot.trivia.comment_ws}" + f"{decode(aot.trivia.comment)}" + f"{trail}" + ) + _key = key.as_string() if prefix is not None: _key = prefix + "." + _key @@ -767,8 +831,10 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: f"{table.trivia.trail}" ) - for k, v in table.value.body: - if isinstance(v, Table): + for k, v in _hoist_empty_aots(table.value.body): + if k is not None and _is_empty_aot(v): + cur += self._render_aot(k, v) + elif isinstance(v, Table): assert k is not None if v.is_super_table(): if k.is_dotted():