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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Add `multiline=True` to `dumps()` and `dump()` to render arrays one item per line without changing the input document.

### Changed

- Speed up repeated array appends by keeping existing indexes instead of rebuilding them.
Expand Down
26 changes: 26 additions & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,29 @@ It can be created with the following code::
>>> database["enabled"] = True

>>> doc["database"] = database


Multiline arrays
~~~~~~~~~~~~~~~~

Pass ``multiline=True`` to ``dumps()`` or ``dump()`` to put each array item on
its own line. This also applies to arrays nested in tables, inline tables,
arrays of tables, and other arrays::

>>> data = {"ports": [8001, 8002]}
>>> print(dumps(data, multiline=True), end="")
ports = [
8001,
8002,
]
>>> print(dumps(data), end="")
ports = [8001, 8002]

The option uses the same formatting as ``Array.multiline(True)`` without
changing the input document or its arrays. Empty arrays remain ``[]``. The
default ``multiline=False`` preserves existing formatting, including arrays
already marked multiline. The option is a boolean; it does not select arrays
by item count or line width.

Custom ``as_string()`` methods keep their existing no-argument interface and
control their own output. The option is not forwarded to those methods.
176 changes: 176 additions & 0 deletions tests/test_api_multiline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import io

from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from typing import Any

import pytest

from tomlkit import array
from tomlkit import dump
from tomlkit import dumps
from tomlkit import item
from tomlkit import parse
from tomlkit.items import Array
from tomlkit.items import Trivia
from tomlkit.toml_document import TOMLDocument


def test_dumps_multiline_mapping_and_dump_stream() -> None:
data = {"z": (1, 2), "a": []}
expected = "a = []\nz = [\n 1,\n 2,\n]\n"
assert dumps(data, True, multiline=True) == expected
stream = io.StringIO()
dump(data, stream, sort_keys=True, multiline=True)
assert stream.getvalue() == expected
assert data == {"z": (1, 2), "a": []}


@pytest.mark.parametrize(
"source, expected",
[
("a = [1, 2]\n", "a = [\n 1,\n 2,\n]\n"),
("[t]\na = [1, 2]\n", "[t]\na = [\n 1,\n 2,\n]\n"),
("a.b = [1, 2]\n", "a.b = [\n 1,\n 2,\n]\n"),
(
"[[t]]\na = [1, 2]\n[[t]]\na = [3]\n",
"[[t]]\na = [\n 1,\n 2,\n]\n[[t]]\na = [\n 3,\n]\n",
),
("a = {b = [1, 2]}\n", "a = {b = [\n 1,\n 2,\n]}\n"),
("a = {b.c = [1, 2]}\n", "a = {b.c = [\n 1,\n 2,\n]}\n"),
(
"a = [[1, 2], [3]]\n",
"a = [\n [\n 1,\n 2,\n],\n [\n 3,\n],\n]\n",
),
(
"a = [{b = [1, 2]}]\n",
"a = [\n {b = [\n 1,\n 2,\n]},\n]\n",
),
(
"[a.b]\nx = [1, 2]\n[z]\nx = [3]\n[a.c]\nx = [4]\n",
"[a.b]\nx = [\n 1,\n 2,\n]\n[z]\nx = [\n 3,\n]\n[a.c]\nx = [\n 4,\n]\n",
),
],
)
def test_dumps_multiline_preserves_document(source: str, expected: str) -> None:
doc = parse(source)
assert dumps(doc, multiline=True) == expected
assert parse(expected) == doc
assert dumps(doc) == source
assert dumps(doc, multiline=False) == source


def test_dumps_multiline_preserves_comments_and_manual_formatting() -> None:
source = "a = [\n # before\n 1, # one\n # between\n 2 # two\n] # array\n"
doc = parse(source)
original_array = doc["a"]
result = dumps(doc, multiline=True)
assert parse(result) == doc
for comment in ["# before", "# one", "# between", "# two", "# array"]:
assert result.count(comment) == 1
assert doc["a"] is original_array
assert original_array._multiline is False
assert dumps(doc) == source

explicit = array("[1, 2]").multiline(True)
assert dumps({"a": explicit}, multiline=False) == "a = [\n 1,\n 2,\n]\n"
assert explicit._multiline is True


@pytest.mark.parametrize("multiline", [False, True])
def test_dumps_multiline_keeps_custom_renderer_signature(multiline: bool) -> None:
class CustomArray(Array):
def as_string(self) -> str:
return "[42]"

class CustomDocument(TOMLDocument):
def as_string(self) -> str:
return "custom = [42]\n"

custom = CustomArray([], Trivia())
assert dumps({"a": custom}, multiline=multiline) == "a = [42]\n"
assert dumps(CustomDocument(), multiline=multiline) == "custom = [42]\n"

class Wrapper:
def as_string(self) -> str:
return "wrapped = [42]\n"

assert dumps(Wrapper(), multiline=multiline) == "wrapped = [42]\n" # type: ignore[arg-type]


def test_nested_dumps_has_its_own_formatting_option() -> None:
inner_results = []

class ReentrantArray(Array):
def as_string(self) -> str:
inner_results.append(dumps({"inner": [7, 8]}))
return "[42]"

result = dumps(
{"custom": ReentrantArray([], Trivia()), "ordinary": [1, 2]}, multiline=True
)
assert inner_results == ["inner = [7, 8]\n"]
assert result == "custom = [42]\nordinary = [\n 1,\n 2,\n]\n"


def test_failed_multiline_dump_does_not_change_the_input() -> None:
class FailingArray(Array):
def as_string(self) -> str:
raise RuntimeError("render failed")

shared = array("[1, 2]")
with pytest.raises(RuntimeError, match="render failed"):
dumps({"a": shared, "b": FailingArray([], Trivia())}, multiline=True)
assert shared._multiline is False
assert shared.as_string() == "[1, 2]"
assert dumps({"a": shared}) == "a = [1, 2]\n"


def test_concurrent_dumps_can_format_the_same_array_differently() -> None:
barrier = Barrier(2)

class SynchronizedArray(Array):
def as_string(self) -> str:
barrier.wait(timeout=10)
return "[]"

shared = array("[1, 2]")
data = {"sync": SynchronizedArray([], Trivia()), "a": shared}
with ThreadPoolExecutor(max_workers=2) as executor:
normal = executor.submit(dumps, data)
multiline = executor.submit(dumps, data, multiline=True)
assert normal.result() == "sync = []\na = [1, 2]\n"
assert multiline.result() == "sync = []\na = [\n 1,\n 2,\n]\n"
assert shared.as_string() == "[1, 2]"


def test_multiline_renders_table_and_inline_table_roots() -> None:
table = item({"a": [1, 2]})
inline = parse("a = {b = [1, 2]}\n")["a"]
assert dumps(table, multiline=True) == "a = [\n 1,\n 2,\n]\n"
assert dumps(inline, multiline=True) == "{b = [\n 1,\n 2,\n]}"
assert dumps(table) == "a = [1, 2]\n"
assert dumps(inline) == "{b = [1, 2]}"


def test_multiline_retains_custom_encoder_arrays() -> None:
from tomlkit import register_encoder
from tomlkit import unregister_encoder
from tomlkit.exceptions import ConvertError

class Value:
pass

shared = array("[1, 2]")

def encoder(value: Any) -> Array:
if isinstance(value, Value):
return shared
raise ConvertError

register_encoder(encoder)
try:
assert dumps({"a": Value()}, multiline=True) == "a = [\n 1,\n 2,\n]\n"
assert shared.as_string() == "[1, 2]"
finally:
unregister_encoder(encoder)
26 changes: 20 additions & 6 deletions tomlkit/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from tomlkit.items import Time
from tomlkit.items import Trivia
from tomlkit.items import Whitespace
from tomlkit.items import _render_item
from tomlkit.items import item as item
from tomlkit.parser import Parser
from tomlkit.toml_document import TOMLDocument as TOMLDocument
Expand All @@ -55,18 +56,24 @@ def loads(string: str | bytes) -> TOMLDocument:
return parse(string)


def dumps(data: Mapping[str, Any], sort_keys: bool = False) -> str:
def dumps(
data: Mapping[str, Any], sort_keys: bool = False, *, multiline: bool = False
) -> str:
"""
Dumps a TOMLDocument into a string.

:param multiline: render arrays one item per line without modifying the input.
False preserves the existing array formatting. Empty arrays stay empty.
Custom as_string() implementations retain control of their formatting.
"""
if isinstance(data, (Table, InlineTable, Container)):
if not sort_keys:
return data.as_string()
return _render_item(data, multiline)

return item(data, _sort_keys=True).as_string()
return _render_item(item(data, _sort_keys=True), multiline)

if isinstance(data, Mapping):
return item(dict(data), _sort_keys=sort_keys).as_string()
return _render_item(item(dict(data), _sort_keys=sort_keys), multiline)

try:
# mapping-like wrappers (e.g. dotty_dict's Dotty) delegate
Expand All @@ -86,19 +93,26 @@ def load(fp: IO[str] | IO[bytes]) -> TOMLDocument:
return parse(fp.read())


def dump(data: Mapping[str, Any], fp: IO[str], *, sort_keys: bool = False) -> None:
def dump(
data: Mapping[str, Any],
fp: IO[str],
*,
sort_keys: bool = False,
multiline: bool = False,
) -> None:
"""
Dump a TOMLDocument into a writable file stream.

:param data: a dict-like object to dump
:param sort_keys: if true, sort the keys in alphabetic order
:param multiline: render arrays one item per line, as in dumps()

:Example:

>>> with open("output.toml", "w") as fp:
... tomlkit.dump(data, fp)
"""
fp.write(dumps(data, sort_keys=sort_keys))
fp.write(dumps(data, sort_keys=sort_keys, multiline=multiline))


def parse(string: str | bytes) -> TOMLDocument:
Expand Down
Loading
Loading