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
41 changes: 41 additions & 0 deletions tests/test_toml_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -1666,3 +1666,44 @@ 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
42 changes: 41 additions & 1 deletion tomlkit/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,10 +630,36 @@ def last_item(self) -> Item | None:
return self._body[-1][1]
return None

def _is_empty_aot(self, item: Item) -> bool:
return isinstance(item, AoT) and not item.body

def as_string(self) -> str:
"""Render as TOML string."""
s = ""
for k, v in self._body:
# An emptied array of tables has no ``[[key]]`` header left to render,
# so it falls back to the inline ``key = []`` form. TOML only reads
# bare key/value pairs before the first table header, so such keys are
# hoisted there instead of being rendered in body order.
hoisted = [
(k, v) for k, v in self._body if k is not None and self._is_empty_aot(v)
]
first_header = next(
(
i
for i, (k, v) in enumerate(self._body)
if isinstance(v, (Table, AoT)) and not self._is_empty_aot(v)
),
None,
)
if first_header is None or not hoisted:
hoisted = []

for i, (k, v) in enumerate(self._body):
if hoisted and i == first_header:
for hk, hv in hoisted:
s += self._render_aot(hk, hv)
if hoisted and k is not None and self._is_empty_aot(v):
continue
if k is not None:
if isinstance(v, Table):
if (
Expand Down Expand Up @@ -745,6 +771,20 @@ def _render_aot(self, key: Key, aot: AoT, prefix: str | None = None) -> str:
if prefix is not None:
_key = prefix + "." + _key

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.
trail = aot.trivia.trail or "\n"
return (
f"{aot.trivia.indent}"
f"{decode(_key)}"
f" = []"
f"{aot.trivia.comment_ws}"
f"{decode(aot.trivia.comment)}"
f"{trail}"
)

cur = ""
_key = decode(_key)
for table in aot.body:
Expand Down