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
68 changes: 68 additions & 0 deletions tests/test_toml_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
76 changes: 71 additions & 5 deletions tomlkit/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(" ")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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():
Expand Down