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]

### Fixed

- `dumps(..., sort_keys=True)` now sorts the keys of a parsed document recursively, matching the behavior on a plain `dict`; previously only the top-level keys were sorted while nested tables, inline tables, arrays of tables and inline tables inside an array kept their original order. ([#614](https://github.com/python-poetry/tomlkit/issues/614))

## [0.15.2] - 2026-10-16

### Changed
Expand Down
63 changes: 63 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,69 @@ def test_parsed_document_can_be_dumped_with_sorted_keys() -> None:
assert dumps(doc, sort_keys=True) == 'aaa = "foo"\nzzz = 1\n'


def test_dumps_with_sorted_keys_recurses_into_a_parsed_document() -> None:
# sort_keys used to sort only the top level of a parsed document, leaving
# nested tables, inline tables and arrays of tables in their original
# order (#614); the same option on a plain dict already sorted recursively.
doc = loads(
"[zeta]\nb = 2\na = 1\n\n"
"[alpha]\ninl = { z = 1, a = 2 }\n\n"
"[[items]]\nz = 1\na = 2\n"
)
original = dumps(doc)

assert dumps(doc, sort_keys=True) == (
"[[items]]\na = 2\nz = 1\n\n"
"[alpha]\ninl = { a = 2, z = 1 }\n\n"
"[zeta]\na = 1\nb = 2\n\n"
)
# A scalar keeps its place before a sub-table section it precedes, so the
# sorted output stays valid TOML rather than folding the key into the table.
nested = loads("[t]\nz = 1\n[t.sub]\nx = 1\n")
assert dumps(nested, sort_keys=True) == "[t]\nz = 1\n[t.sub]\nx = 1\n"

# Dumping with sorted keys must not mutate the document.
assert dumps(doc) == original


def test_dumps_with_sorted_keys_sorts_inline_tables_inside_arrays() -> None:
# sort_keys must reach inline tables nested inside an ordinary array, not
# just tables, inline tables and arrays of tables, while keeping the
# array's element ORDER (a list is ordered data, not a key set). And
# reordering a tight inline table's keys must not leave a stray separator
# space on the new first key (#617 review).
doc = loads(
"""\
arr = [{z = 2, a = 1}, {y = 3, b = 4}]
mix = [1, {n = 1, m = 2}, "s"]
"""
)
original = dumps(doc)

assert dumps(doc, sort_keys=True) == (
"""\
arr = [{a = 1, z = 2}, {b = 4, y = 3}]
mix = [1, {m = 2, n = 1}, "s"]
"""
)
# A tight inline table keeps its compact spacing after sorting -- the
# reordered first key must not gain a leading separator space.
assert (
dumps(
loads("""\
t = {z=2, a=1}
"""),
sort_keys=True,
)
== """\
t = {a=1, z=2}
"""
)

# Sorting must not mutate the document.
assert dumps(doc) == original


def test_dumps_weird_object() -> None:
with pytest.raises(TypeError):
dumps(object()) # type: ignore[arg-type]
Expand Down
98 changes: 98 additions & 0 deletions tomlkit/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from typing import Protocol

from tomlkit import container
from tomlkit.container import Container
from tomlkit.container import OutOfOrderTableProxy

class Encoder(Protocol):
Expand Down Expand Up @@ -111,6 +112,90 @@ def item(value: ItemT, _parent: Item | None = ..., _sort_keys: bool = ...) -> It
def item(value: object, _parent: Item | None = ..., _sort_keys: bool = ...) -> Item: ...


def _sort_container_body(container: Container, inline: bool = False) -> None:
"""Reorder *container*'s entries by key, in place, and recurse.

For a regular table each key keeps the comment and whitespace lines that
precede it, and any trailing lines after the last key stay at the end. For
an INLINE table the separators between entries (a ``,`` and the spaces
around it) are positional whitespace rather than trivia owned by the
following key, so only the real ``(key, value)`` entries are permuted
among their slots while every separator stays put -- otherwise the entry
moved to the front would drag a leading separator space with it, sorting
``{z=2, a=1}`` to ``{ a=1, z=2}``. Containers holding an out-of-order
table (a key spread over several body positions) are left in place, since
their entries cannot be linearly reordered, but their nested tables are
still sorted.
"""
out_of_order = any(isinstance(idx, tuple) for idx in container._map.values())

if not out_of_order and inline:
# Permute only the keyed entries; leave every separator in place.
real_positions = [
i for i, (key, _v) in enumerate(container._body) if key is not None
]
real_entries = [container._body[i] for i in real_positions]
real_entries.sort(key=lambda kv: (isinstance(kv[1], dict), kv[0].key))
for position, entry in zip(real_positions, real_entries):
container._body[position] = entry
container._map = {}
for index, (key, _value) in enumerate(container._body):
if key is not None:
container._map[key] = index
elif not out_of_order:
groups: list[tuple[tuple[bool, str], list[tuple[Key | None, Item]]]] = []
pending: list[tuple[Key | None, Item]] = []
for key, value in container._body:
if key is None:
pending.append((key, value))
else:
# Same order as the plain-dict branch of ``item``: a value that
# renders as its own table section sorts after the inline
# values, so a key never ends up under a preceding section
# header.
sort_key = (isinstance(value, dict), key.key)
groups.append((sort_key, [*pending, (key, value)]))
pending = []

groups.sort(key=lambda group: group[0])

new_body: list[tuple[Key | None, Item]] = []
for _, entries in groups:
new_body.extend(entries)
new_body.extend(pending)

container._body[:] = new_body
container._map = {}
for index, (key, _value) in enumerate(new_body):
if key is not None:
container._map[key] = index

for _key, value in container._body:
_sort_item_keys(value)


def _sort_item_keys(value: Item) -> None:
"""Sort the keys of *value* in place if it is a table-like item."""
if isinstance(value, InlineTable):
_sort_container_body(value.value, inline=True)
elif isinstance(value, Table):
_sort_container_body(value.value)
elif isinstance(value, AoT):
for table in value.body:
if isinstance(table, InlineTable):
_sort_container_body(table.value, inline=True)
elif isinstance(table, Table):
_sort_container_body(table.value)
elif isinstance(value, Array):
# Recurse into array ELEMENTS: an inline table (or a nested array
# of them) can sit inside an ordinary array, and its keys must sort
# too. The array's own element ORDER is left untouched -- a list is
# ordered data, not a key set, so only the keys within each
# table-like element are reordered.
for element in value:
_sort_item_keys(element)


def item(value: Any, _parent: Item | None = None, _sort_keys: bool = False) -> Item:
"""Create a TOML item from a Python object.

Expand All @@ -128,6 +213,19 @@ def item(value: Any, _parent: Item | None = None, _sort_keys: bool = False) -> I
from tomlkit.container import Container

if isinstance(value, Item):
# An already-built item is returned as is, except that ``_sort_keys``
# must reach the keys of a parsed table too: without this, only the
# top-level container (which reaches the ``dict`` branch below) gets
# sorted, while nested tables, inline tables, arrays of tables and
# inline tables inside an ordinary array keep their original order
# (#614). A sorted copy is returned so the caller's document is not
# mutated. ``Array`` is included so an inline table sitting inside a
# top-level array is reached too; ``_sort_item_keys`` recurses into
# array elements while leaving their order untouched.
if _sort_keys and isinstance(value, (Table, InlineTable, AoT, Array)):
sorted_value = copy.deepcopy(value)
_sort_item_keys(sorted_value)
return sorted_value
return value

if isinstance(value, bool):
Expand Down
Loading