toml-tidy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toml_tidy-0.1.0/LICENSE +21 -0
- toml_tidy-0.1.0/PKG-INFO +75 -0
- toml_tidy-0.1.0/README.md +51 -0
- toml_tidy-0.1.0/pyproject.toml +87 -0
- toml_tidy-0.1.0/src/toml_tidy/__init__.py +1 -0
- toml_tidy-0.1.0/src/toml_tidy/cli.py +87 -0
- toml_tidy-0.1.0/src/toml_tidy/sorter.py +423 -0
toml_tidy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dongmin Yu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
toml_tidy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toml-tidy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sort TOML keys without changing table hierarchy
|
|
5
|
+
Keywords: toml,sort,formatter,cli,tomlkit,pyproject
|
|
6
|
+
Author: Dongmin Yu
|
|
7
|
+
Author-email: Dongmin Yu <ydm2790@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
18
|
+
Requires-Dist: tomlkit>=0.15.0,<0.16
|
|
19
|
+
Requires-Dist: typer>=0.16,<1
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Repository, https://github.com/AndrewDongminYoo/toml_tidy
|
|
22
|
+
Project-URL: Issues, https://github.com/AndrewDongminYoo/toml_tidy/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# toml-tidy
|
|
26
|
+
|
|
27
|
+
Sort TOML keys while preserving table hierarchy and source formatting where `tomlkit` supports it.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv tool install .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
toml-tidy pyproject.toml
|
|
39
|
+
toml-tidy pyproject.toml --check
|
|
40
|
+
toml-tidy pyproject.toml --in-place --order natural
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Without `--in-place`, sorted TOML is written to standard output.
|
|
44
|
+
|
|
45
|
+
`--check` writes nothing and exits with status `1` when the file requires sorting.
|
|
46
|
+
|
|
47
|
+
`--in-place` rewrites the file only when sorting changes it.
|
|
48
|
+
|
|
49
|
+
## Ordering
|
|
50
|
+
|
|
51
|
+
`natural` is the default and compares digit runs numerically, so `item2` precedes `item10`.
|
|
52
|
+
|
|
53
|
+
`alpha` uses case-insensitive lexical order.
|
|
54
|
+
|
|
55
|
+
Both modes compare TOML's parsed logical key, not source quoting.
|
|
56
|
+
|
|
57
|
+
Dotted keys such as `b.a = 2` sort with their sibling direct keys by their parsed dotted path, segment by segment, so `a` precedes `b.a`, which precedes `b.z`.
|
|
58
|
+
|
|
59
|
+
For example, `[plugins.omo]` precedes `[plugins."omo-kit"]`, while the quoted spelling remains unchanged in output.
|
|
60
|
+
|
|
61
|
+
## Preservation
|
|
62
|
+
|
|
63
|
+
Direct keys and sibling explicit table declarations are sorted recursively within their parent table.
|
|
64
|
+
|
|
65
|
+
Array-of-tables declarations such as `[[items]]` sort by name among their sibling tables, while the element order inside each array of tables remains unchanged.
|
|
66
|
+
|
|
67
|
+
Parent-child hierarchy remains unchanged.
|
|
68
|
+
|
|
69
|
+
Standalone comments move with the following key or table declaration.
|
|
70
|
+
|
|
71
|
+
Whitespace between entries remains after the preceding entry, and trailing whitespace remains at its table boundary.
|
|
72
|
+
|
|
73
|
+
Inline comments, value formatting, and key quoting remain attached to their parsed `tomlkit` items.
|
|
74
|
+
|
|
75
|
+
Keys inside inline tables are not reordered.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# toml-tidy
|
|
2
|
+
|
|
3
|
+
Sort TOML keys while preserving table hierarchy and source formatting where `tomlkit` supports it.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
toml-tidy pyproject.toml
|
|
15
|
+
toml-tidy pyproject.toml --check
|
|
16
|
+
toml-tidy pyproject.toml --in-place --order natural
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Without `--in-place`, sorted TOML is written to standard output.
|
|
20
|
+
|
|
21
|
+
`--check` writes nothing and exits with status `1` when the file requires sorting.
|
|
22
|
+
|
|
23
|
+
`--in-place` rewrites the file only when sorting changes it.
|
|
24
|
+
|
|
25
|
+
## Ordering
|
|
26
|
+
|
|
27
|
+
`natural` is the default and compares digit runs numerically, so `item2` precedes `item10`.
|
|
28
|
+
|
|
29
|
+
`alpha` uses case-insensitive lexical order.
|
|
30
|
+
|
|
31
|
+
Both modes compare TOML's parsed logical key, not source quoting.
|
|
32
|
+
|
|
33
|
+
Dotted keys such as `b.a = 2` sort with their sibling direct keys by their parsed dotted path, segment by segment, so `a` precedes `b.a`, which precedes `b.z`.
|
|
34
|
+
|
|
35
|
+
For example, `[plugins.omo]` precedes `[plugins."omo-kit"]`, while the quoted spelling remains unchanged in output.
|
|
36
|
+
|
|
37
|
+
## Preservation
|
|
38
|
+
|
|
39
|
+
Direct keys and sibling explicit table declarations are sorted recursively within their parent table.
|
|
40
|
+
|
|
41
|
+
Array-of-tables declarations such as `[[items]]` sort by name among their sibling tables, while the element order inside each array of tables remains unchanged.
|
|
42
|
+
|
|
43
|
+
Parent-child hierarchy remains unchanged.
|
|
44
|
+
|
|
45
|
+
Standalone comments move with the following key or table declaration.
|
|
46
|
+
|
|
47
|
+
Whitespace between entries remains after the preceding entry, and trailing whitespace remains at its table boundary.
|
|
48
|
+
|
|
49
|
+
Inline comments, value formatting, and key quoting remain attached to their parsed `tomlkit` items.
|
|
50
|
+
|
|
51
|
+
Keys inside inline tables are not reordered.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "toml-tidy"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Sort TOML keys without changing table hierarchy"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Dongmin Yu", email = "ydm2790@gmail.com" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
keywords = ["toml", "sort", "formatter", "cli", "tomlkit", "pyproject"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
19
|
+
"Topic :: Text Processing :: Markup",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["tomlkit>=0.15.0,<0.16", "typer>=0.16,<1"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/AndrewDongminYoo/toml_tidy"
|
|
25
|
+
Issues = "https://github.com/AndrewDongminYoo/toml_tidy/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
toml-tidy = "toml_tidy.cli:app"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = ["basedpyright>=1.29", "pytest>=8", "ruff>=0.11"]
|
|
36
|
+
|
|
37
|
+
[tool.basedpyright]
|
|
38
|
+
typeCheckingMode = "all"
|
|
39
|
+
pythonVersion = "3.12"
|
|
40
|
+
pythonPlatform = "All"
|
|
41
|
+
include = ["src", "tests"]
|
|
42
|
+
exclude = ["**/__pycache__", "**/.venv", "**/build", "**/dist"]
|
|
43
|
+
reportUnusedCallResult = "warning"
|
|
44
|
+
reportUnnecessaryTypeIgnoreComment = "error"
|
|
45
|
+
reportUnusedVariable = "error"
|
|
46
|
+
reportMissingParameterType = "error"
|
|
47
|
+
reportPrivateUsage = "error"
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py312"
|
|
51
|
+
line-length = 88
|
|
52
|
+
src = ["src", "tests"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["ALL"]
|
|
56
|
+
ignore = [
|
|
57
|
+
"COM812",
|
|
58
|
+
"CPY001",
|
|
59
|
+
"D203",
|
|
60
|
+
"D213",
|
|
61
|
+
"FBT001",
|
|
62
|
+
"FBT002",
|
|
63
|
+
"FIX002",
|
|
64
|
+
"ISC001",
|
|
65
|
+
"Q003",
|
|
66
|
+
"TD002",
|
|
67
|
+
"TD003",
|
|
68
|
+
"TRY003",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.per-file-ignores]
|
|
72
|
+
"tests/**/*.py" = ["ARG", "D", "PLR2004", "S101", "SLF001"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.pydocstyle]
|
|
75
|
+
convention = "google"
|
|
76
|
+
|
|
77
|
+
[tool.ruff.format]
|
|
78
|
+
quote-style = "double"
|
|
79
|
+
indent-style = "space"
|
|
80
|
+
docstring-code-format = true
|
|
81
|
+
docstring-code-line-length = "dynamic"
|
|
82
|
+
|
|
83
|
+
[tool.pytest.ini_options]
|
|
84
|
+
minversion = "8.0"
|
|
85
|
+
testpaths = ["tests"]
|
|
86
|
+
addopts = ["-ra", "--strict-config", "--strict-markers"]
|
|
87
|
+
filterwarnings = ["error"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hierarchical TOML key sorting."""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command-line interface for hierarchical TOML sorting."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Final
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from tomlkit.exceptions import TOMLKitError
|
|
9
|
+
|
|
10
|
+
from toml_tidy.sorter import OrderMode, sort_toml
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Sort TOML keys while preserving table hierarchy.")
|
|
13
|
+
_MUTUALLY_EXCLUSIVE_OPTIONS: Final = "--in-place and --check cannot be used together"
|
|
14
|
+
_LONE_LF: Final = re.compile(r"(?<!\r)\n")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _detect_linesep(content: str) -> str:
|
|
18
|
+
"""Detect the file's dominant line ending, mirroring TOMLFile.read()."""
|
|
19
|
+
num_newline = content.count("\n")
|
|
20
|
+
if num_newline == 0:
|
|
21
|
+
return "\n"
|
|
22
|
+
num_win_eol = content.count("\r\n")
|
|
23
|
+
if num_win_eol == num_newline:
|
|
24
|
+
return "\r\n"
|
|
25
|
+
if num_win_eol == 0:
|
|
26
|
+
return "\n"
|
|
27
|
+
return "mixed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply_linesep(content: str, linesep: str) -> str:
|
|
31
|
+
"""Restore a previously detected line ending, mirroring TOMLFile.write().
|
|
32
|
+
|
|
33
|
+
"mixed" input (inconsistent endings) is passed through unchanged: the
|
|
34
|
+
caller never normalized it before parsing, so tomlkit's per-line trivia
|
|
35
|
+
already preserves each original ending.
|
|
36
|
+
"""
|
|
37
|
+
if linesep == "\r\n":
|
|
38
|
+
return _LONE_LF.sub("\r\n", content)
|
|
39
|
+
if linesep == "\n":
|
|
40
|
+
return content.replace("\r\n", "\n")
|
|
41
|
+
return content
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def sort_file(
|
|
46
|
+
path: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
|
|
47
|
+
in_place: Annotated[bool, typer.Option("--in-place")] = False,
|
|
48
|
+
check: Annotated[bool, typer.Option("--check")] = False,
|
|
49
|
+
order: Annotated[OrderMode, typer.Option("--order")] = OrderMode.NATURAL,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Sort TOML keys while preserving table hierarchy."""
|
|
52
|
+
if in_place and check:
|
|
53
|
+
raise typer.BadParameter(_MUTUALLY_EXCLUSIVE_OPTIONS)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
with path.open(encoding="utf-8", newline="") as handle:
|
|
57
|
+
raw_source = handle.read()
|
|
58
|
+
linesep = _detect_linesep(raw_source)
|
|
59
|
+
# Mixed endings: parse the raw source unchanged so tomlkit's per-line
|
|
60
|
+
# trivia keeps each line's original ending, mirroring TOMLFile's
|
|
61
|
+
# passthrough behavior instead of flattening everything to LF.
|
|
62
|
+
source = raw_source if linesep == "mixed" else raw_source.replace("\r\n", "\n")
|
|
63
|
+
sorted_source = sort_toml(source, order)
|
|
64
|
+
except (TOMLKitError, UnicodeDecodeError, OSError, RecursionError) as error:
|
|
65
|
+
typer.echo(f"{path}: {error}", err=True)
|
|
66
|
+
raise typer.Exit(code=2) from None
|
|
67
|
+
|
|
68
|
+
if check:
|
|
69
|
+
if source != sorted_source:
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
output = _apply_linesep(sorted_source, linesep)
|
|
74
|
+
|
|
75
|
+
if in_place:
|
|
76
|
+
if source != sorted_source:
|
|
77
|
+
try:
|
|
78
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
79
|
+
_ = handle.write(output)
|
|
80
|
+
except OSError as error:
|
|
81
|
+
typer.echo(f"{path}: {error}", err=True)
|
|
82
|
+
raise typer.Exit(code=2) from None
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Bytes bypass text-mode newline translation: on Windows a str write would
|
|
86
|
+
# rewrite the already-restored "\r\n" endings to "\r\r\n".
|
|
87
|
+
typer.echo(output.encode("utf-8"), nl=False)
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Sort TOML key entries while retaining parsed formatting items."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import tomlkit
|
|
8
|
+
from tomlkit.container import Container
|
|
9
|
+
from tomlkit.items import AoT, Comment, Item, Key, Table, Whitespace
|
|
10
|
+
|
|
11
|
+
type BodyEntry = tuple[Key | None, Item]
|
|
12
|
+
type SegmentKind = Literal["key", "table"]
|
|
13
|
+
type SortKey = tuple[tuple[int, str | tuple[int, str]], ...]
|
|
14
|
+
|
|
15
|
+
_NATURAL_PARTS = re.compile(r"(\d+)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OrderMode(StrEnum):
|
|
19
|
+
"""Supported direct-key ordering modes."""
|
|
20
|
+
|
|
21
|
+
NATURAL = "natural"
|
|
22
|
+
ALPHA = "alpha"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sort_toml(source: str, order: OrderMode = OrderMode.NATURAL) -> str:
|
|
26
|
+
"""Return source with direct keys sorted recursively."""
|
|
27
|
+
# A trailing lone "\r" is invalid TOML; appending "\n" would turn it into
|
|
28
|
+
# a valid CRLF line instead of letting the parser reject it.
|
|
29
|
+
if source and not source.endswith(("\n", "\r")):
|
|
30
|
+
source += "\n"
|
|
31
|
+
document = tomlkit.parse(source)
|
|
32
|
+
_sort_document(document, order)
|
|
33
|
+
return tomlkit.dumps(document)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sort_document(container: Container, order: OrderMode) -> None:
|
|
37
|
+
"""Sort a container's tree, then restore key-to-index map consistency.
|
|
38
|
+
|
|
39
|
+
``_sort_container`` reorders and splices bodies throughout the tree
|
|
40
|
+
(slice assignment, sibling-table merges, comment re-attachment), all of
|
|
41
|
+
which bypass tomlkit's private ``_map`` bookkeeping. A mutation made
|
|
42
|
+
while restoring one container's comment attachment can delete entries
|
|
43
|
+
from an already-visited descendant's body, so rebuilding each
|
|
44
|
+
container's map inline during the recursive sort is not safe: the
|
|
45
|
+
rebuild has to run once, after every mutation in the tree is done.
|
|
46
|
+
"""
|
|
47
|
+
_sort_container(container, order)
|
|
48
|
+
_restore_maps(container)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _restore_maps(container: Container) -> None:
|
|
52
|
+
"""Rebuild this container's map and recurse into every descendant table."""
|
|
53
|
+
_rebuild_map(container)
|
|
54
|
+
for _, item in container.body:
|
|
55
|
+
match item:
|
|
56
|
+
case Table():
|
|
57
|
+
_restore_maps(item.value)
|
|
58
|
+
case AoT():
|
|
59
|
+
for table in item.body:
|
|
60
|
+
_restore_maps(table.value)
|
|
61
|
+
case _:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sort_container(container: Container, order: OrderMode) -> None:
|
|
66
|
+
"""Sort direct key segments and then visit descendant table containers."""
|
|
67
|
+
container.body[:] = _sort_segments(container.body, order)
|
|
68
|
+
|
|
69
|
+
for _, item in container.body:
|
|
70
|
+
match item:
|
|
71
|
+
case Table():
|
|
72
|
+
_sort_container(item.value, order)
|
|
73
|
+
case AoT():
|
|
74
|
+
for table in item.body:
|
|
75
|
+
_sort_container(table.value, order)
|
|
76
|
+
case _:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
container.body[:] = _restore_comment_attachment(container.body)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rebuild_map(container: Container) -> None:
|
|
83
|
+
"""Rebuild a container's key-to-index map to match its current body.
|
|
84
|
+
|
|
85
|
+
Sorting reorders ``body`` via direct slice assignment and splicing,
|
|
86
|
+
which bypasses tomlkit's ``append``/``_raw_append`` bookkeeping that
|
|
87
|
+
keeps the private ``_map`` in sync. Left stale, key lookups on the live
|
|
88
|
+
document (e.g. ``doc['a']``) resolve through the old index and return
|
|
89
|
+
whatever entry now occupies that slot. This mirrors ``_raw_append``'s
|
|
90
|
+
map bookkeeping: a repeated key (out-of-order tables) collects a tuple
|
|
91
|
+
of every index it occupies instead of a single int.
|
|
92
|
+
"""
|
|
93
|
+
new_map: dict[Key, int | tuple[int, ...]] = {}
|
|
94
|
+
for index, (key, _) in enumerate(container.body):
|
|
95
|
+
if key is None:
|
|
96
|
+
continue
|
|
97
|
+
existing = new_map.get(key)
|
|
98
|
+
if existing is None:
|
|
99
|
+
new_map[key] = index
|
|
100
|
+
elif isinstance(existing, tuple):
|
|
101
|
+
new_map[key] = (*existing, index)
|
|
102
|
+
else:
|
|
103
|
+
new_map[key] = (existing, index)
|
|
104
|
+
container._map = new_map # pyright: ignore[reportPrivateUsage] # noqa: SLF001
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _sort_segments(entries: list[BodyEntry], order: OrderMode) -> list[BodyEntry]:
|
|
108
|
+
"""Sort direct keys and sibling tables without crossing their boundaries."""
|
|
109
|
+
sorted_entries: list[BodyEntry] = []
|
|
110
|
+
segment: list[BodyEntry] = []
|
|
111
|
+
segment_kind: SegmentKind | None = None
|
|
112
|
+
|
|
113
|
+
for entry in entries:
|
|
114
|
+
key, item = entry
|
|
115
|
+
match item:
|
|
116
|
+
case Table() | AoT() if key is None or not key.is_dotted():
|
|
117
|
+
if segment_kind == "key":
|
|
118
|
+
# The key segment's trailing comment run annotates the
|
|
119
|
+
# upcoming table header; carry it into the table segment
|
|
120
|
+
# so it travels as that table's leading-comment group.
|
|
121
|
+
tail = len(segment)
|
|
122
|
+
while tail and segment[tail - 1][0] is None:
|
|
123
|
+
tail -= 1
|
|
124
|
+
kept, carried = _split_before_first_comment(segment[tail:])
|
|
125
|
+
segment = [*segment[:tail], *kept]
|
|
126
|
+
sorted_entries.extend(_sort_segment(segment, order))
|
|
127
|
+
segment = carried
|
|
128
|
+
segment.append(entry)
|
|
129
|
+
segment_kind = "table"
|
|
130
|
+
case _:
|
|
131
|
+
if key is not None and segment_kind == "table":
|
|
132
|
+
sorted_entries.extend(_sort_segment(segment, order))
|
|
133
|
+
segment = []
|
|
134
|
+
segment.append(entry)
|
|
135
|
+
if key is not None:
|
|
136
|
+
segment_kind = "key"
|
|
137
|
+
|
|
138
|
+
sorted_entries.extend(_sort_segment(segment, order))
|
|
139
|
+
return sorted_entries
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _sort_segment(entries: list[BodyEntry], order: OrderMode) -> list[BodyEntry]:
|
|
143
|
+
"""Move leading comments with keys while keeping whitespace after prior keys."""
|
|
144
|
+
entries = _hoist_header_comments(entries)
|
|
145
|
+
leading: list[BodyEntry] = []
|
|
146
|
+
pending: list[BodyEntry] = []
|
|
147
|
+
groups: list[tuple[tuple[str, ...], list[BodyEntry]]] = []
|
|
148
|
+
|
|
149
|
+
for entry in entries:
|
|
150
|
+
key, item = entry
|
|
151
|
+
if key is None:
|
|
152
|
+
pending.append(entry)
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
whitespace, comments = _split_before_first_comment(pending)
|
|
156
|
+
pending = []
|
|
157
|
+
if groups:
|
|
158
|
+
groups[-1][1].extend(whitespace)
|
|
159
|
+
else:
|
|
160
|
+
leading.extend(whitespace)
|
|
161
|
+
groups.append((_key_path(key, item), [*comments, entry]))
|
|
162
|
+
|
|
163
|
+
groups = _merge_sibling_tables(groups)
|
|
164
|
+
sorted_groups = sorted(
|
|
165
|
+
groups,
|
|
166
|
+
key=lambda group: _path_sort_key(group[0], order),
|
|
167
|
+
)
|
|
168
|
+
return [
|
|
169
|
+
*leading,
|
|
170
|
+
*(entry for _, group in sorted_groups for entry in group),
|
|
171
|
+
*pending,
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _merge_sibling_tables(
|
|
176
|
+
groups: list[tuple[tuple[str, ...], list[BodyEntry]]],
|
|
177
|
+
) -> list[tuple[tuple[str, ...], list[BodyEntry]]]:
|
|
178
|
+
"""Fold duplicate-key sibling table entries into a single body entry.
|
|
179
|
+
|
|
180
|
+
``[a.y] … [b] … [a.x]`` parses as two body entries keyed ``a``. Sorting
|
|
181
|
+
them as separate entries is never idempotent: reparsing the output merges
|
|
182
|
+
them into one super table whose children then sort together, so pass 2
|
|
183
|
+
could interleave children across the pass-1 entry boundary. Merging here
|
|
184
|
+
makes pass 1 produce the shape a reparse would.
|
|
185
|
+
"""
|
|
186
|
+
merged: list[tuple[tuple[str, ...], list[BodyEntry]]] = []
|
|
187
|
+
indexes: dict[str, int] = {}
|
|
188
|
+
|
|
189
|
+
for path, entries in groups:
|
|
190
|
+
key, item = entries[-1]
|
|
191
|
+
if key is None or key.is_dotted() or not isinstance(item, Table):
|
|
192
|
+
merged.append((path, entries))
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
index = indexes.get(key.key)
|
|
196
|
+
if index is not None:
|
|
197
|
+
_, dest_item = merged[index][1][-1]
|
|
198
|
+
if isinstance(dest_item, Table) and (
|
|
199
|
+
dest_item.is_super_table() or item.is_super_table()
|
|
200
|
+
):
|
|
201
|
+
if item.is_super_table():
|
|
202
|
+
_splice_super_table(dest_item, item, entries[:-1])
|
|
203
|
+
else:
|
|
204
|
+
# The concrete table owns the header; fold the super in.
|
|
205
|
+
_splice_super_table(item, dest_item, merged[index][1][:-1])
|
|
206
|
+
merged[index] = (path, entries)
|
|
207
|
+
surviving = merged[index][1]
|
|
208
|
+
merged[index] = (_key_path(key, surviving[-1][1]), surviving)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
indexes[key.key] = len(merged)
|
|
212
|
+
merged.append((path, entries))
|
|
213
|
+
return merged
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _splice_super_table(dest: Table, src: Table, comments: list[BodyEntry]) -> None:
|
|
217
|
+
"""Move src's body (with its hoisted leading comments) under dest.
|
|
218
|
+
|
|
219
|
+
Descends the shared single-child super-table chain so the splice happens
|
|
220
|
+
at the first level where the chains diverge, keeping each comment
|
|
221
|
+
directly above the concrete header it annotates. Duplicates created at a
|
|
222
|
+
deeper level are folded when that container's segment is sorted.
|
|
223
|
+
"""
|
|
224
|
+
while True:
|
|
225
|
+
children = [entry for entry in src.value.body if entry[0] is not None]
|
|
226
|
+
if len(children) != 1:
|
|
227
|
+
break
|
|
228
|
+
child_key, child_item = children[0]
|
|
229
|
+
if not isinstance(child_item, Table) or not child_item.is_super_table():
|
|
230
|
+
break
|
|
231
|
+
existing = None
|
|
232
|
+
for entry_key, entry_item in dest.value.body:
|
|
233
|
+
if entry_key == child_key and isinstance(entry_item, Table):
|
|
234
|
+
existing = entry_item
|
|
235
|
+
break
|
|
236
|
+
if existing is None or not existing.is_super_table():
|
|
237
|
+
break
|
|
238
|
+
dest, src = existing, child_item
|
|
239
|
+
dest.value.body.extend([*comments, *src.value.body])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _split_before_first_comment(
|
|
243
|
+
trivia: list[BodyEntry],
|
|
244
|
+
) -> tuple[list[BodyEntry], list[BodyEntry]]:
|
|
245
|
+
"""Split a trivia run at its first comment, keeping the interleaved order."""
|
|
246
|
+
first_comment = next(
|
|
247
|
+
(i for i, (_, item) in enumerate(trivia) if not isinstance(item, Whitespace)),
|
|
248
|
+
len(trivia),
|
|
249
|
+
)
|
|
250
|
+
return trivia[:first_comment], trivia[first_comment:]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _hoist_header_comments(entries: list[BodyEntry]) -> list[BodyEntry]:
|
|
254
|
+
"""Splice each table's trailing comment run in front of the next sibling.
|
|
255
|
+
|
|
256
|
+
A standalone comment written directly above a ``[table]`` or ``[[aot]]``
|
|
257
|
+
header is parsed into the tail of the previous declaration's body, so
|
|
258
|
+
reordering siblings would strand it. Hoisting it to the segment level lets
|
|
259
|
+
it travel as the next declaration's leading-comment group. The last
|
|
260
|
+
declaration keeps its tail; an AoT's tail lives in its last element.
|
|
261
|
+
"""
|
|
262
|
+
table_indexes = [
|
|
263
|
+
index
|
|
264
|
+
for index, (key, item) in enumerate(entries)
|
|
265
|
+
if isinstance(item, Table | AoT) and (key is None or not key.is_dotted())
|
|
266
|
+
]
|
|
267
|
+
if not table_indexes:
|
|
268
|
+
return entries
|
|
269
|
+
|
|
270
|
+
hoisted: list[BodyEntry] = []
|
|
271
|
+
for index, entry in enumerate(entries):
|
|
272
|
+
hoisted.append(entry)
|
|
273
|
+
if index in table_indexes[:-1]:
|
|
274
|
+
match entry[1]:
|
|
275
|
+
case Table() as table:
|
|
276
|
+
hoisted.extend(_pop_trailing_comment_run(table.value))
|
|
277
|
+
case AoT() as aot if aot.body:
|
|
278
|
+
hoisted.extend(_pop_trailing_comment_run(aot.body[-1].value))
|
|
279
|
+
case _:
|
|
280
|
+
pass
|
|
281
|
+
return hoisted
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _restore_comment_attachment(entries: list[BodyEntry]) -> list[BodyEntry]:
|
|
285
|
+
"""Re-nest container-level comment runs the way a reparse would attach them.
|
|
286
|
+
|
|
287
|
+
tomlkit renders a super table's implicit header as soon as a comment
|
|
288
|
+
entry sits directly in its body, so a comment left at container level by
|
|
289
|
+
hoisting or splicing would materialize an ``[a]`` line on the next pass.
|
|
290
|
+
Sinking each trivia run that follows a table into that table's deepest
|
|
291
|
+
tail, and lifting a comment run that leads a super table's body up to the
|
|
292
|
+
parent, reproduces the shape the parser builds — keeping repeated sorts
|
|
293
|
+
byte-identical.
|
|
294
|
+
"""
|
|
295
|
+
result: list[BodyEntry] = []
|
|
296
|
+
target: Container | None = None
|
|
297
|
+
|
|
298
|
+
for entry in entries:
|
|
299
|
+
key, item = entry
|
|
300
|
+
if key is None and target is not None:
|
|
301
|
+
target.body.append(entry)
|
|
302
|
+
continue
|
|
303
|
+
if isinstance(item, Table) and item.is_super_table():
|
|
304
|
+
body = item.value.body
|
|
305
|
+
end = 0
|
|
306
|
+
while end < len(body) and body[end][0] is None:
|
|
307
|
+
end += 1
|
|
308
|
+
whitespace, comments = _split_before_first_comment(body[:end])
|
|
309
|
+
if comments:
|
|
310
|
+
del body[len(whitespace) : end]
|
|
311
|
+
if target is not None:
|
|
312
|
+
target.body.extend(comments)
|
|
313
|
+
else:
|
|
314
|
+
result.extend(comments)
|
|
315
|
+
result.append(entry)
|
|
316
|
+
match item:
|
|
317
|
+
case Table() if key is None or not key.is_dotted():
|
|
318
|
+
target = _trailing_container(item.value)
|
|
319
|
+
case AoT() if item.body:
|
|
320
|
+
target = _trailing_container(item.body[-1].value)
|
|
321
|
+
case _:
|
|
322
|
+
target = None
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _trailing_container(container: Container) -> Container | None:
|
|
327
|
+
"""Return the deepest container a reparse would attach trailing trivia to."""
|
|
328
|
+
while container.body:
|
|
329
|
+
key, item = container.body[-1]
|
|
330
|
+
match item:
|
|
331
|
+
case Table() if key is None or not key.is_dotted():
|
|
332
|
+
container = item.value
|
|
333
|
+
case AoT():
|
|
334
|
+
if not item.body:
|
|
335
|
+
return None
|
|
336
|
+
container = item.body[-1].value
|
|
337
|
+
case _:
|
|
338
|
+
break
|
|
339
|
+
return container
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _pop_trailing_comment_run(container: Container) -> list[BodyEntry]:
|
|
343
|
+
"""Remove and return the trailing comment run of the deepest last body.
|
|
344
|
+
|
|
345
|
+
The run starts at the first comment of the body's trailing trivia; pure
|
|
346
|
+
whitespace before it stays at the table boundary, while whitespace
|
|
347
|
+
interleaved with the comments moves along in its original order.
|
|
348
|
+
"""
|
|
349
|
+
target = _trailing_container(container)
|
|
350
|
+
if target is None:
|
|
351
|
+
return []
|
|
352
|
+
|
|
353
|
+
body = target.body
|
|
354
|
+
start = len(body)
|
|
355
|
+
for index in range(len(body) - 1, -1, -1):
|
|
356
|
+
key, item = body[index]
|
|
357
|
+
if key is not None or not isinstance(item, Comment | Whitespace):
|
|
358
|
+
break
|
|
359
|
+
if isinstance(item, Comment):
|
|
360
|
+
start = index
|
|
361
|
+
|
|
362
|
+
run = body[start:]
|
|
363
|
+
del body[start:]
|
|
364
|
+
return run
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _key_path(key: Key, item: Item) -> tuple[str, ...]:
|
|
368
|
+
"""Return a key's logical segments, expanding dotted keys and super tables.
|
|
369
|
+
|
|
370
|
+
``[a.y]`` at root parses as a super-table entry keyed ``a``; two such
|
|
371
|
+
siblings would otherwise compare equal, keep their original order, and
|
|
372
|
+
only sort after a reparse merges them — breaking idempotence. Walking
|
|
373
|
+
single-child super tables recovers the effective header path instead.
|
|
374
|
+
"""
|
|
375
|
+
path = [key.key]
|
|
376
|
+
while isinstance(item, Table) and item.is_super_table():
|
|
377
|
+
children = [
|
|
378
|
+
(child_key, child_item)
|
|
379
|
+
for child_key, child_item in item.value.body
|
|
380
|
+
if child_key is not None
|
|
381
|
+
]
|
|
382
|
+
if len(children) != 1:
|
|
383
|
+
break
|
|
384
|
+
child_key, item = children[0]
|
|
385
|
+
path.append(child_key.key)
|
|
386
|
+
return tuple(path)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _path_sort_key(
|
|
390
|
+
path: tuple[str, ...], order: OrderMode
|
|
391
|
+
) -> tuple[tuple[SortKey, ...], tuple[str, ...]]:
|
|
392
|
+
"""Compare normalized segments first; raw spelling breaks full-path ties only.
|
|
393
|
+
|
|
394
|
+
Folding the raw key into each segment would let a case-only difference in
|
|
395
|
+
an early segment (``A`` vs ``a``) decide the order before later segments
|
|
396
|
+
are compared, misplacing ``A.z`` after ``a.a``.
|
|
397
|
+
"""
|
|
398
|
+
return tuple(_sort_key(segment, order) for segment in path), path
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _sort_key(key: str, order: OrderMode) -> SortKey:
|
|
402
|
+
"""Produce a comparable key from a TOML key's parsed logical value."""
|
|
403
|
+
match order:
|
|
404
|
+
case OrderMode.NATURAL:
|
|
405
|
+
return _natural_key(key)
|
|
406
|
+
case OrderMode.ALPHA:
|
|
407
|
+
return ((0, key.casefold()),)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _natural_key(key: str) -> SortKey:
|
|
411
|
+
"""Return case-insensitive text and numeric runs in natural comparison order."""
|
|
412
|
+
parts: list[tuple[int, str | tuple[int, str]]] = []
|
|
413
|
+
|
|
414
|
+
for part in _NATURAL_PARTS.split(key):
|
|
415
|
+
if part.isdecimal():
|
|
416
|
+
# (digit count, digits) compares numerically without materializing
|
|
417
|
+
# an int, which would raise past sys.get_int_max_str_digits().
|
|
418
|
+
digits = part.lstrip("0")
|
|
419
|
+
parts.append((1, (len(digits), digits)))
|
|
420
|
+
else:
|
|
421
|
+
parts.append((0, part.casefold()))
|
|
422
|
+
|
|
423
|
+
return tuple(parts)
|