Metadata-Version: 2.4
Name: toml-tidy
Version: 0.7.0
Summary: Sort TOML keys without changing table hierarchy
Keywords: toml,sort,formatter,cli,tomlkit,pyproject
Author: Dongmin Yu
Author-email: Dongmin Yu <ydm2790@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Text Processing :: Markup
Requires-Dist: tomlkit>=0.15.0,<0.16
Requires-Dist: typer>=0.16,<1
Requires-Python: >=3.12
Project-URL: Repository, https://github.com/AndrewDongminYoo/toml_tidy
Project-URL: Issues, https://github.com/AndrewDongminYoo/toml_tidy/issues
Description-Content-Type: text/markdown

# toml-tidy

![Abstract TOML sorting transformation](.github/assets/readme-hero.png)

[![CI](https://github.com/AndrewDongminYoo/toml-tidy/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/AndrewDongminYoo/toml-tidy/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/toml-tidy)](https://pypi.org/project/toml-tidy/)
[![Supported Python versions](https://img.shields.io/pypi/pyversions/toml-tidy)](https://pypi.org/project/toml-tidy/)
[![MIT license](https://img.shields.io/pypi/l/toml-tidy)](LICENSE)
[![pre-commit integration](https://img.shields.io/badge/integration-pre--commit-FAB040?logo=precommit&logoColor=white)](#pre-commit)
[![Trunk integration](https://img.shields.io/badge/integration-Trunk-4F46E5)](#trunk)

Sort TOML keys while preserving table hierarchy and source formatting where `tomlkit` supports it.

## Install

Install from [PyPI](https://pypi.org/project/toml-tidy/):

```bash
pip install toml-tidy
```

Or as a standalone tool with `uv`:

```bash
uv tool install toml-tidy
```

Or run it once without installing:

```bash
uvx toml-tidy pyproject.toml
```

For development from a local checkout, use `uv tool install .`.

## Usage

```bash
toml-tidy pyproject.toml
toml-tidy pyproject.toml --check
toml-tidy pyproject.toml --diff
toml-tidy pyproject.toml --in-place --order natural
toml-tidy config/*.toml --in-place --scope tables
```

The command accepts one or more file paths and processes each one, so it works directly as a pre-commit or trunk formatter target.

Without `--in-place`, sorted TOML is written to standard output; this mode takes exactly one path so separate documents never get concatenated.

`--check` writes nothing and exits with status `1` when any file requires sorting.

`--diff` also writes nothing: it prints a unified diff for each file that requires sorting, against exactly what `--in-place` would write, and exits with status `1` when any file requires sorting.
The diff keeps each file's own line endings, so `patch` applies it to the file it came from.
Each header carries the path as it was given on the command line, so pass relative paths and run `patch -p0` from the same directory: GNU `patch` ignores an absolute or `..` header path as unsafe.
A file that requires sorting but whose path `patch` would not read back as exactly that name (leading or trailing whitespace, a control character such as a TAB or line break, or a leading double quote) gets an error with status `2` instead of a diff.
`--diff` cannot be combined with `--in-place` or `--check`.

`--in-place` rewrites each file only when sorting changes it.

With multiple paths the worst exit code wins: `2` for any error, else `1` for any `--check` or `--diff` difference, else `0`; an error in one file does not stop the remaining files.

`--scope` limits what gets sorted: `all` (default) sorts everything, `tables` sorts only sibling table declarations, and `keys` sorts only direct key-value entries.

`--blank-lines` additionally normalizes blank lines to exactly one before every table header and none anywhere else; `--no-blank-lines` (the default) leaves every blank line where it was.

`--line-width` expands single-line arrays whose line would be wider than the given column count; with no value set, every array keeps its own layout.

## Configuration

Defaults can be set in a `.toml-tidy.toml` or `toml-tidy.toml` file, with the keys at the top level, or under `[tool.toml-tidy]` in a `pyproject.toml`.
CLI flags always override the configuration.

```toml
# toml-tidy.toml
order = "natural"   # "alpha" | "version"
scope = "all"       # "tables" | "keys"
first = ["project", "build-system"]
blank-lines = false # true normalizes blank lines
line-width = 88     # omit to leave array layout alone
```

`first` pins top-level entries by name, in the listed order, ahead of their sorted siblings; it never applies inside nested tables, and it has no CLI flag.

Unknown keys are rejected so configuration typos cannot silently fall back to defaults.

Discovery follows [Ruff's rules](https://docs.astral.sh/ruff/configuration/#config-file-discovery):

- Each target file uses the closest configuration file found walking up from its directory to the filesystem root.
- In one directory, `.toml-tidy.toml` wins over `toml-tidy.toml`, which wins over `pyproject.toml`.
  A dedicated file applies even when it is empty.
- A `pyproject.toml` counts only when it contains a `[tool.toml-tidy]` table; one without the table is skipped and the walk continues upward, so a sub-package's `pyproject.toml` does not hide the root configuration.
- Settings are never merged: the closest file is used, and every file above it, or a `[tool.toml-tidy]` table beside a winning dedicated file, is ignored.
- `--config <path>` uses the given dedicated file or `pyproject.toml` for every target and skips discovery.
  A `pyproject.toml` without the table means the defaults.
  A missing or invalid `--config` file exits with status `2` before any target is processed.
- When the hierarchy holds no configuration file, the first existing file among `.toml-tidy.toml`, `toml-tidy.toml` and `pyproject.toml` in the user-level `toml-tidy` directory applies; if there is none, or that directory cannot be located or searched, the defaults apply.
  On Linux and macOS that directory is `$XDG_CONFIG_HOME/toml-tidy` when `XDG_CONFIG_HOME` is an absolute path, else `~/.config/toml-tidy`.
  On Windows it is `%APPDATA%\toml-tidy`, else `~\AppData\Roaming\toml-tidy`.

## pre-commit

```yaml
repos:
  - repo: https://github.com/AndrewDongminYoo/toml-tidy
    rev: v0.7.0 # pin the latest release; the hook ships from v0.2.0 onward
    hooks:
      - id: toml-tidy
```

The hook runs `toml-tidy --in-place`, and `args` are appended to it, so flags are set per repository without losing in-place rewriting:

```yaml
hooks:
  - id: toml-tidy
    args: [--blank-lines, --order, alpha]
```

From v0.7.0, the hook only runs on files ending in `.toml` and on `Pipfile`, so generated lockfiles such as `uv.lock`, `poetry.lock` and `Cargo.lock` are left alone without an `exclude`.

## Trunk

`toml-tidy` is available as an opt-in formatter through [Trunk](https://trunk.io/).

See the [official Trunk plugin definition](https://github.com/trunk-io/plugins/tree/main/linters/toml-tidy) for setup and runtime requirements.

## Ordering

`natural` is the default and compares digit runs numerically, so `item2` precedes `item10`.

`alpha` uses case-insensitive lexical order.

`version` follows the [Rust style guide's version sorting](https://doc.rust-lang.org/nightly/style-guide/index.html#sorting) as rustfmt implements it, the order used for Cargo manifests.
Unlike `natural`, it is case-sensitive, so `B` precedes `a`; each `_` sorts right after a space and before any other character, as the guide states rather than first as rustfmt does, so `item_a` precedes `item1`; only ASCII digits form numbers; and among equal numbers the one with more leading zeros comes first, so `v00` precedes `v0`.

All modes compare TOML's parsed logical key, not source quoting.

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`.

For example, `[plugins.omo]` precedes `[plugins."omo-kit"]`, while the quoted spelling remains unchanged in output.

## Preservation

Direct keys and sibling explicit table declarations are sorted recursively within their parent table.

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.

Parent-child hierarchy remains unchanged.

Standalone comments move with the following key or table declaration.

Whitespace between entries remains after the preceding entry, and trailing whitespace remains at its table boundary, unless `--blank-lines` is enabled.

Inline comments and key quoting remain attached to their parsed `tomlkit` items.

Every non-empty single-line array uses one space after `[` and before `]`, and one space after each separating comma with none before it.
A trailing comma keeps its place at the end of the array.
Empty and multi-line arrays keep their source layout.

Keys inside inline tables are not reordered.

## Line width

`--line-width` (config: `line-width = 88`) is unset by default and runs after sorting.
It only ever expands an array onto several lines; it never joins a multi-line array back into one.

- An array is measured as the whole line it renders on, including indentation and the key, and is expanded when that line exceeds the given width.
- A comment trailing the array is not counted, because expanding the array moves the comment but cannot shorten it.
- Only an array that is the direct value of a key is measured, since only that array occupies a line of its own. Arrays nested inside another array or an inline table share their parent's line and are left alone.
- An array that already spans several lines is never measured or rejoined, so a second run changes nothing.
- An empty array is never expanded, because doing so cannot make its line shorter.
- An expanded array reuses the line ending of the line it sits on, so a CRLF or mixed-ending document keeps its endings.

## Blank lines

`--blank-lines` (config: `blank-lines = true`) is off by default and runs after sorting.
It rewrites blank lines only, never comments or values:

- Exactly one blank line precedes every table and array-of-tables header, above the comment run attached to that header rather than between the comment and the header.
- No blank lines remain between key-value entries, inside a comment run, or at the end of the file.
- The document's first rendered line never gains a blank line above it.
- Blank lines inside multi-line string values belong to the value, not to the layout, and are untouched.

Given this input:

```toml
a = 1

b = 2
[x]
p = 1


[y]
q = 1
```

`toml-tidy --blank-lines` produces:

```toml
a = 1
b = 2

[x]
p = 1

[y]
q = 1
```

The result is stable, so `--check` reports a file once and reports it clean after `--in-place` fixes it.

## Development

Run the same quality gates enforced by CI before committing:

```bash
uv run pytest
uv run ruff check src tests
uv run ruff format --check src tests
uv run basedpyright
```

CI runs the test suite on every supported Python version and against the lower and upper tested `tomlkit` patch releases because the sorter intentionally isolates a dependency on `tomlkit`'s private container representation.

## Releasing

A release is prepared on a `release/vX.Y.Z` branch: bump `project.version`, run `uv lock`, date the CHANGELOG section and add its compare link, and update the pre-commit `rev` above.

**Merge that pull request first, then tag the merge commit and push the tag.** Pushing the tag runs the tests, publishes to PyPI, and creates the GitHub Release from the tagged CHANGELOG section. A tag on any commit `main` has never pointed at is refused before anything is published — tagging a branch tip puts the artifact on PyPI before that branch's own review has landed on it, and the commit that was merged does not carry what the review added.
