Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions tests/test_toml_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
109 changes: 89 additions & 20 deletions tomlkit/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(" ")
Expand Down Expand Up @@ -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 = "[[", "]]"
Expand All @@ -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")
Expand Down Expand Up @@ -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():
Expand Down