From 6e192d07925d1927a618c1f99fd02648ded5083c Mon Sep 17 00:00:00 2001 From: Sreekant Baheti <60787859+Sreekant13@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:05:40 -0700 Subject: [PATCH 1/4] Sort parsed documents recursively with sort_keys (#614) dumps(doc, sort_keys=True) sorted only the top-level keys of a parsed document: item() returned an already-parsed Table/InlineTable/AoT unchanged, so only the top-level container reached the sorting rebuild. A plain dict, whose nested values are plain dicts, already sorted recursively. item() now returns a sorted copy of a parsed table-like item. The copy reorders each container's body by key, keeps every key's leading comment and whitespace lines with it, orders a value that renders as its own section after the inline values so a key never folds under a preceding header, keeps inline tables inline, and leaves the input document unmutated. A container holding an out-of-order table is left in place, since its entries cannot be linearly reordered, but its nested tables are still sorted. --- CHANGELOG.md | 6 +++++ tests/test_api.py | 25 +++++++++++++++++++ tomlkit/items.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3737b80e..f130efc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index b5f31bc3..7ab9ec2d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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] diff --git a/tomlkit/items.py b/tomlkit/items.py index c950e5d0..aba49a9b 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -111,6 +111,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. @@ -128,6 +181,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): From b7c07ea40bf9548ee1655a518a54e0f1b98295f9 Mon Sep 17 00:00:00 2001 From: Sreekant Baheti <60787859+Sreekant13@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:07:56 -0700 Subject: [PATCH 2/4] Import Container under TYPE_CHECKING for the sort helper annotation The _sort_container_body annotation referenced Container, which items.py otherwise imports lazily inside item(); add it to the TYPE_CHECKING block so ruff no longer flags F821 (undefined name). --- tomlkit/items.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tomlkit/items.py b/tomlkit/items.py index aba49a9b..77078a26 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -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): From f0699acb45d64b0a91f368a0d211430fe836754d Mon Sep 17 00:00:00 2001 From: Sreekant Baheti <60787859+Sreekant13@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:21:18 -0700 Subject: [PATCH 3/4] Sort inline tables nested inside arrays, and keep their tight spacing _sort_item_keys descended into Table, InlineTable and AoT but not an ordinary Array, so sort_keys left an inline table inside an array unsorted: `values = [{z=2, a=1}]` stayed as-is. It now recurses into array elements, sorting the keys of any table-like element while leaving the array's own element order untouched (a list is ordered data, not a key set). Reordering an inline table's keys also dragged the leading separator space onto the new first key, so a tight `{z=2, a=1}` sorted to `{ a=1, z=2}`. An inline table's separators are positional whitespace, not trivia owned by the following key, so _sort_container_body now permutes only the keyed entries among their slots for an inline table, leaving every separator in place: `{z=2, a=1}` sorts to `{a=1, z=2}` and a spaced table keeps its spacing. Addresses the review on #617. --- CHANGELOG.md | 2 +- tests/test_api.py | 32 +++++++++++++++++++++++++ tomlkit/items.py | 60 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abcb2d6c..a8458fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### 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)) +- `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 diff --git a/tests/test_api.py b/tests/test_api.py index 613d8068..29f11cfa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -217,6 +217,38 @@ def test_dumps_with_sorted_keys_recurses_into_a_parsed_document() -> None: 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] diff --git a/tomlkit/items.py b/tomlkit/items.py index f1ddcdf6..0a221809 100644 --- a/tomlkit/items.py +++ b/tomlkit/items.py @@ -112,18 +112,37 @@ 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: +def _sort_container_body(container: Container, inline: bool = False) -> 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. + 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: + 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: @@ -157,12 +176,24 @@ def _sort_container_body(container: Container) -> None: 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)): + 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, (Table, InlineTable)): + 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: @@ -185,10 +216,13 @@ def item(value: Any, _parent: Item | None = None, _sort_keys: bool = False) -> I # 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, 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 From 7f5da8f149b5d97bd611b74b8fb56fbb8046e62a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:31:37 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 29f11cfa..38550c2d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -239,11 +239,17 @@ def test_dumps_with_sorted_keys_sorts_inline_tables_inside_arrays() -> None: ) # A tight inline table keeps its compact spacing after sorting -- the # reordered first key must not gain a leading separator space. - assert dumps(loads("""\ + assert ( + dumps( + loads("""\ t = {z=2, a=1} -"""), sort_keys=True) == """\ +"""), + sort_keys=True, + ) + == """\ t = {a=1, z=2} """ + ) # Sorting must not mutate the document. assert dumps(doc) == original