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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change Log

## [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 and arrays of tables kept their original order. ([#614](https://github.com/python-poetry/tomlkit/issues/614))

## [0.15.1] - 2026-07-17

### Changed
Expand Down
25 changes: 25 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,31 @@ 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_weird_object() -> None:
with pytest.raises(TypeError):
dumps(object()) # type: ignore[arg-type]
Expand Down
64 changes: 64 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,59 @@ 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) -> None:
"""Reorder *container*'s entries by key, in place, and recurse.

Each key keeps the comment and whitespace lines that precede it, and any
trailing lines after the last key stay at the end. 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:
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, (Table, InlineTable)):
_sort_container_body(value.value)
elif isinstance(value, AoT):
for table in value.body:
if isinstance(table, (Table, InlineTable)):
_sort_container_body(table.value)


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 +182,16 @@ 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 and arrays of tables keep
# their original order (#614). A sorted copy is returned so the caller's
# document is not mutated.
if _sort_keys and isinstance(value, (Table, InlineTable, AoT)):
sorted_value = copy.deepcopy(value)
_sort_item_keys(sorted_value)
return sorted_value
return value

if isinstance(value, bool):
Expand Down
Loading