diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py index 7ba61f5e..be776a14 100644 --- a/tests/test_toml_document.py +++ b/tests/test_toml_document.py @@ -1666,3 +1666,109 @@ 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"]), + # Parent table left implicit: there is no `[t]` header to make `a` + # bare, so the fallback has to keep the `t.` prefix. + ("[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # ... two levels of implicit parent. + ("[[t.u.a]]\nx = 1\n", lambda doc: doc["t"]["u"]["a"]), + # ... with an unrelated header before it, which the fallback must not + # be read into. + ("[x]\nq = 1\n\n[[t.a]]\nx = 1\n", lambda doc: doc["t"]["a"]), + # ... and with a sibling sub-table, so the implicit parent survives + # into the rendered output as a header of its own. + ("[x]\nq = 1\n\n[[t.a]]\nx = 1\n\n[t.b]\ny = 3\n", lambda doc: doc["t"]["a"]), + # Explicit grandparent, implicit parent: the fallback is relative to + # the nearest header, so it is `u.a`, not `t.u.a`. + ("[t]\n\n[[t.u.a]]\nx = 1\n", lambda doc: doc["t"]["u"]["a"]), + ("[t]\n\n[[t.u.a]]\nx = 1\n\n[t.u.b]\ny = 3\n", lambda doc: doc["t"]["u"]["a"]), + # Implicit parent inside an array-of-tables element. + ("[[e]]\nn = 1\n\n[[e.s.a]]\nx = 1\n", lambda doc: doc["e"][0]["s"]["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() + + +def test_emptied_array_of_tables_under_implicit_parent_keeps_its_path() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # A super table emits no header, so it opens no scope for the fallback to + # be bare in. Dropping the prefix here moved the key to the root. + doc = parse("[[t.a]]\nx = 1\n") + doc["t"]["a"].pop() + + assert doc.as_string() == "t.a = []\n" + + +def test_emptied_array_of_tables_is_relative_to_the_nearest_header() -> None: + # https://github.com/python-poetry/tomlkit/issues/553 + # `[t]` is emitted, `t.u` is not, so the fallback is written relative to + # `[t]`. Spelling it in full emitted `t.u.a = []` under `[t]`, which reads + # back as `t.t.u.a`. + doc = parse("[t]\n[[t.u.a]]\nx = 1\n") + doc["t"]["u"]["a"].pop() + + assert doc.as_string() == "[t]\nu.a = []\n" diff --git a/tomlkit/container.py b/tomlkit/container.py index 8ff30d98..ed081fce 100644 --- a/tomlkit/container.py +++ b/tomlkit/container.py @@ -32,6 +32,76 @@ _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 _emits_header(key: Key, table: Table) -> bool: + """Whether rendering ``table`` under ``key`` emits a ``[header]`` line. + + A super table normally renders as nothing but the path prefix of its + children, so it opens no scope of its own. + """ + return ( + not table.is_super_table() + or ( + any( + not isinstance(v, (Table, AoT, Whitespace, Null)) + for _, v in table.value.body + ) + and not key.is_dotted() + ) + or ( + any( + k is not None and k.is_dotted() + for k, v in table.value.body + if isinstance(v, Table) + ) + and not key.is_dotted() + ) + ) + + +def _header_less_aots( + body: list[tuple[Key | None, Item]], prefix: str = "" +) -> list[tuple[str, AoT]]: + """Every emptied array of tables reachable without crossing a header. + + An emptied array of tables has no ``[[key]]`` header left to render, so it + falls back to the inline ``key = []`` form -- and TOML reads a bare + key/value pair into whichever table the closest preceding header opened. + So the fallbacks of a scope have to be written at the top of that scope, + with a key relative to it, and that includes the ones sitting inside super + tables, which emit no header to separate them. + + Returns ``(dotted key, aot)`` pairs, keyed relative to the scope ``body`` + belongs to. + """ + found: list[tuple[str, AoT]] = [] + for k, v in body: + if k is None: + continue + path = f"{prefix}.{k.as_string()}" if prefix else k.as_string() + if _is_empty_aot(v): + found.append((path, v)) + elif isinstance(v, Table) and not _emits_header(k, v): + found.extend(_header_less_aots(v.value.body, path)) + return found + + +def _render_empty_aot(key: str, aot: AoT) -> str: + """Render an emptied array of tables as an inline empty array.""" + return ( + f"{aot.trivia.indent}" + f"{decode(key)}" + f" = []" + f"{aot.trivia.comment_ws}" + f"{decode(aot.trivia.comment)}" + f"{aot.trivia.trail or chr(10)}" + ) + + class Container(_CustomDict): # type: ignore[type-arg] """ A container for items within a TOMLDocument. @@ -633,8 +703,12 @@ def last_item(self) -> Item | None: def as_string(self) -> str: """Render as TOML string.""" s = "" + for path, aot in _header_less_aots(self._body): + s += _render_empty_aot(path, aot) for k, v in self._body: if k is not None: + if _is_empty_aot(v): + continue if isinstance(v, Table): if ( s.strip(" ") @@ -669,24 +743,8 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st if prefix is not None: _key = prefix + "." + _key - if ( - not table.is_super_table() - or ( - any( - not isinstance(v, (Table, AoT, Whitespace, Null)) - for _, v in table.value.body - ) - and not key.is_dotted() - ) - or ( - any( - k is not None and k.is_dotted() - for k, v in table.value.body - if isinstance(v, Table) - ) - and not key.is_dotted() - ) - ): + header_emitted = _emits_header(key, table) + if header_emitted: open_, close = "[", "]" if table.is_aot_element(): open_, close = "[[", "]]" @@ -707,8 +765,15 @@ def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> st elif table.trivia.indent == "\n": cur += table.trivia.indent + if header_emitted: + # A super table opens no scope, so its fallbacks belong to -- and + # were already written by -- the nearest enclosing header instead. + for path, aot in _header_less_aots(table.value.body): + cur += _render_empty_aot(path, aot) for k, v in table.value.body: - if isinstance(v, Table): + if k is not None and _is_empty_aot(v): + continue + elif isinstance(v, Table): if ( cur.strip(" ") and not cur.strip(" ").endswith("\n") @@ -767,8 +832,12 @@ def _render_aot_table(self, table: Table, prefix: str | None = None) -> str: f"{table.trivia.trail}" ) + for path, aot in _header_less_aots(table.value.body): + cur += _render_empty_aot(path, aot) for k, v in table.value.body: - if isinstance(v, Table): + if k is not None and _is_empty_aot(v): + continue + elif isinstance(v, Table): assert k is not None if v.is_super_table(): if k.is_dotted():