diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cff684..18481f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,12 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Added `FormatOptions` for configuring canonical formatting.
+
+### Deprecated
+
+- The `comments` argument to `format()`; use
+ `FormatOptions(normalize_comments=...)`.
+
### Fixed
- `promote_inline` and `promote_array` now preserve entry key and value
formatting
- `set_multiline()` now preserves closing-bracket indentation for nested
arrays and inline tables.
+- `format()` now preserves closing-bracket indentation when called on a
+ nested array or inline table directly.
## [2.0.2] - 2026-07-10
diff --git a/docs/api.md b/docs/api.md
index d642abc..9b57f76 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -12,6 +12,7 @@ semver-stable API:
| `loads`, `load` | function |
| `dumps`, `dump` | function |
| `Document`, `Table`, `Array`, `AoT` | class |
+| `FormatOptions` | class |
| `TomlInput` | type alias |
| `TOMLError`, `TOMLParseError` | exception |
@@ -26,6 +27,16 @@ code.
::: tomlrt.dumps
::: tomlrt.dump
+## Formatting
+
+::: tomlrt.FormatOptions
+ options:
+ members:
+ - normalize_comments
+ - indent
+ - eol_comment_spaces
+ - multiline_trailing_comma
+
## Containers
::: tomlrt.Document
diff --git a/docs/layout.md b/docs/layout.md
index 9ebd0f3..dc308c4 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -103,8 +103,19 @@ The operations above are format-preserving. When you instead want to
_opt in_ to canonical formatting — to drop the original spacing of a
single value, a section, or the whole document — call `format()`.
-`Container.format(*, comments=True)` and `Array.format(*, comments=True)`
-both mutate in place and return `None`, mirroring `list.sort()`.
+`Container.format()` and `Array.format()` both mutate in place and return
+`None`, mirroring `list.sort()`. Pass a `FormatOptions` object to configure
+canonical formatting consistently at any scope.
+
+| Option | Default | Effect |
+|--------|---------|--------|
+| `normalize_comments` | `True` | Normalize comment text. |
+| `indent` | `2` | Spaces added per nested multiline inline value. |
+| `eol_comment_spaces` | `1` | Spaces before supported EOL comments. |
+| `multiline_trailing_comma` | `True` | Emit a final comma in multiline arrays and inline tables. |
+
+For example, `doc.format(options=tomlrt.FormatOptions(indent=4))` uses a
+four-space step at every nested multiline level.
```python
import tomlrt
@@ -163,11 +174,11 @@ x = { a = 1, b = 2 }
- Slots outside the receiver's subtree are not touched: calling
`format()` on a single section leaves sibling sections alone.
-### The `comments` flag
+### Comment normalization
-When `comments=True` (the default), every comment reached by the walk
-is rewritten so that there is exactly one space between `#` and the
-body, and any trailing whitespace inside the comment is stripped:
+By default, every comment reached by the walk is rewritten so that there is
+exactly one space between `#` and the body, and any trailing whitespace inside
+the comment is stripped:
| Before | After |
|--------|-------|
@@ -176,7 +187,12 @@ body, and any trailing whitespace inside the comment is stripped:
| # foo | # foo |
| # (empty) | # |
-Pass `comments=False` to leave comment text untouched.
+Pass `FormatOptions(normalize_comments=False)` to leave comment text untouched.
+The former `comments=` keyword remains available for compatibility but is
+deprecated; do not pass it together with `options=`.
+
+`eol_comment_spaces` applies to key/value, section-header, array-item, and
+inline-table-entry comments. Opening-bracket comment spacing remains authored.
### Detached views
diff --git a/src/tomlrt/__init__.py b/src/tomlrt/__init__.py
index a74e166..032ef46 100644
--- a/src/tomlrt/__init__.py
+++ b/src/tomlrt/__init__.py
@@ -4,12 +4,14 @@
from tomlrt._container import AoT, Array, Document, Table, TomlInput
from tomlrt._errors import TOMLError, TOMLParseError
+from tomlrt._format import FormatOptions
from tomlrt._public import dump, dumps, load, loads
__all__ = [
"AoT",
"Array",
"Document",
+ "FormatOptions",
"TOMLError",
"TOMLParseError",
"Table",
diff --git a/src/tomlrt/_array.py b/src/tomlrt/_array.py
index 2bd1902..9e2e913 100644
--- a/src/tomlrt/_array.py
+++ b/src/tomlrt/_array.py
@@ -31,7 +31,8 @@
)
from tomlrt._errors import TOMLError
from tomlrt._format import (
- _canon_inline_value,
+ _resolve_format_options,
+ format_inline_root,
set_comma_value_multiline,
)
from tomlrt._trivia import (
@@ -54,6 +55,7 @@
from tomlrt._comma_ops import (
CommaStyle,
)
+ from tomlrt._format import FormatOptions
from tomlrt._trivia import TriviaPiece
from tomlrt._values import (
Value,
@@ -221,18 +223,24 @@ def leading_comments(self) -> MutableMapping[int, tuple[str, ...]]:
"""Leading-comment view, indexed by item position."""
return ArrayLeadingView(self)
- def format(self, *, comments: bool = True) -> None:
+ def format(
+ self,
+ *,
+ options: FormatOptions | None = None,
+ comments: bool | None = None,
+ ) -> None:
"""Canonicalise this array's formatting in place.
Rewrites whitespace, indentation, separators, and newlines
while preserving shape (single-line stays single-line, multi-line
stays multi-line) and orphan comment text.
- When ``comments`` is true (the default), comment text is also
- normalised: ``#foo`` and ``# foo`` both become ``# foo``, and
- trailing whitespace inside comments is stripped.
+ ``comments=`` is deprecated; use
+ ``FormatOptions(normalize_comments=...)`` instead. Supplying both
+ arguments raises ``ValueError``.
"""
- _canon_inline_value(self._value, nl=self._doc_newline, comments=comments)
+ resolved = _resolve_format_options(options=options, comments=comments)
+ format_inline_root(self._value, nl=self._doc_newline, options=resolved)
def set_multiline(self, *, multiline: bool, indent: int = 4) -> Array:
"""Switch this array between flush single-line and multi-line form.
diff --git a/src/tomlrt/_container.py b/src/tomlrt/_container.py
index 4a82971..97ff6d1 100644
--- a/src/tomlrt/_container.py
+++ b/src/tomlrt/_container.py
@@ -38,10 +38,11 @@
from tomlrt._errors import TOMLError
from tomlrt._format import (
_canon_header_slot,
- _canon_inline_value,
_canon_kv_slot,
_canon_leading,
+ _resolve_format_options,
format_document_trailing,
+ format_inline_root,
format_subtree,
)
from tomlrt._inline_comments import InlineEolView, InlineLeadingView
@@ -77,6 +78,7 @@
from _typeshed import SupportsKeysAndGetItem, SupportsRichComparison
from typing_extensions import Self
+ from tomlrt._format import FormatOptions
from tomlrt._slots import AoTEntry, Slot, SlotRef
from tomlrt._trivia import EolTrivia, TriviaPiece
from tomlrt._values import (
@@ -266,7 +268,12 @@ def _doc_newline(self) -> str:
lr = self._layout_root
return lr._newline if lr is not None else "\n" # noqa: SLF001
- def format(self, *, comments: bool = True) -> None:
+ def format(
+ self,
+ *,
+ options: FormatOptions | None = None,
+ comments: bool | None = None,
+ ) -> None:
"""Canonicalise this container's formatting in place.
Rewrites this subtree to the canonical layout:
@@ -280,19 +287,21 @@ def format(self, *, comments: bool = True) -> None:
multi-line stays multi-line).
* Newlines use the owning document's style.
- With ``comments=True``, ``#foo`` and ``# foo`` both become
- ``# foo`` and trailing comment whitespace is stripped.
+ ``comments=`` is deprecated; use
+ ``FormatOptions(normalize_comments=...)`` instead. Supplying both
+ arguments raises ``ValueError``.
Detached factory-style containers (``Table.section()`` /
``Table.inline()`` not yet assigned anywhere) and inline dotted
navigators are unsupported and raise `TOMLError`.
"""
+ resolved = _resolve_format_options(options=options, comments=comments)
kind = self._kind
nl = self._doc_newline
if kind is _Kind.INLINE_ROOT:
assert self._value is not None
- _canon_inline_value(self._value, nl=nl, comments=comments)
+ format_inline_root(self._value, nl=nl, options=resolved)
return
if kind in (_Kind.INLINE_FACTORY, _Kind.INLINE_DOTTED_INNER):
@@ -306,10 +315,10 @@ def format(self, *, comments: bool = True) -> None:
path=(),
owner=None,
nl=nl,
- comments=comments,
+ options=resolved,
)
- format_document_trailing(self._preamble, nl=nl, comments=comments)
- format_document_trailing(self._trailing, nl=nl, comments=comments)
+ format_document_trailing(self._preamble, nl=nl, options=resolved)
+ format_document_trailing(self._trailing, nl=nl, options=resolved)
return
if kind is _Kind.SECTION:
@@ -319,7 +328,7 @@ def format(self, *, comments: bool = True) -> None:
path=self._path,
owner=self._owner_aot_entry,
nl=nl,
- comments=comments,
+ options=resolved,
)
return
@@ -331,16 +340,16 @@ def format(self, *, comments: bool = True) -> None:
for ref in list(self._refs):
slot = ref.slot
if isinstance(slot, KVSlot):
- _canon_kv_slot(slot, nl=nl, comments=comments)
+ _canon_kv_slot(slot, nl=nl, options=resolved)
elif isinstance(slot, StructuralHeaderSlot):
- _canon_header_slot(slot, nl=nl, comments=comments)
- _canon_leading(slot, nl=nl, target_blanks=None, comments=comments)
+ _canon_header_slot(slot, nl=nl, options=resolved)
+ _canon_leading(slot, nl=nl, target_blanks=None, options=resolved)
for value in self.values():
if isinstance(value, (Container, Array)):
- value.format(comments=comments)
+ value.format(options=resolved)
elif isinstance(value, AoT):
for entry in value:
- entry.format(comments=comments)
+ entry.format(options=resolved)
@property
def _attached(self) -> bool:
diff --git a/src/tomlrt/_format.py b/src/tomlrt/_format.py
index ba26c8b..a01a61e 100644
--- a/src/tomlrt/_format.py
+++ b/src/tomlrt/_format.py
@@ -8,7 +8,7 @@
KV slot
pre_eq=" ", post_eq=" ", key_seps=".", strip column-indent WS,
- EOL trailing_ws = " " before any comment.
+ configurable EOL spacing before comments.
Section / AoT-entry header
inner_pre="", inner_post="", strip column-indent WS, EOL as for KV.
@@ -20,21 +20,21 @@
Inline arrays / inline tables
Single-line: ``[a, b, c]`` / ``{ a = 1, b = 2 }``.
- Multi-line: each item on its own line, last item carries a
- trailing comma. Indent inherited from the first item if present
- else two spaces.
+ Multi-line: each item on its own line with configurable recursive
+ indentation and a configurable final comma.
Orphan comment blocks (``# …`` runs separated from the slot/header
by a blank line) and EOL / leading-attached comments are preserved
in place — comment text is rewritten to ``# body`` form when
-``comments=True``.
+``normalize_comments`` is enabled.
"""
from __future__ import annotations
+import warnings
+from dataclasses import dataclass
from typing import TYPE_CHECKING
-from tomlrt._comma_ops import _put_eol, _take_eol
from tomlrt._errors import TOMLError
from tomlrt._slots import KVSlot, StructuralHeaderSlot
from tomlrt._trivia import (
@@ -55,6 +55,7 @@
ArrayValue,
InlineTableEntry,
InlineTableValue,
+ item_eol_channel,
item_has_any_comment,
)
@@ -72,6 +73,65 @@
)
+def _validate_non_negative(value: int, name: str) -> None:
+ if value < 0:
+ msg = f"{name} must be non-negative"
+ raise ValueError(msg)
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class FormatOptions:
+ """Canonical formatting options shared by all ``format()`` methods.
+
+ ``normalize_comments`` rewrites comment text to canonical ``# body`` form
+ and strips trailing whitespace. Layout around comments is canonicalised
+ regardless.
+
+ ``indent`` is the number of spaces added at each nested multiline array
+ or inline-table level.
+
+ ``eol_comment_spaces`` is the number of spaces inserted before supported
+ end-of-line comments.
+
+ ``multiline_trailing_comma`` controls whether the final item in a multiline
+ array or inline table has a comma.
+ """
+
+ normalize_comments: bool = True
+ indent: int = 2
+ eol_comment_spaces: int = 1
+ multiline_trailing_comma: bool = True
+
+ def __post_init__(self) -> None:
+ _validate_non_negative(self.indent, "indent")
+ _validate_non_negative(self.eol_comment_spaces, "eol_comment_spaces")
+
+
+_DEFAULT_FORMAT_OPTIONS = FormatOptions()
+
+
+def _resolve_format_options(
+ *,
+ options: FormatOptions | None,
+ comments: bool | None,
+) -> FormatOptions:
+ """Resolve public formatting arguments and warn for ``comments=``."""
+ if options is not None and comments is not None:
+ msg = "cannot specify both options and deprecated comments"
+ raise ValueError(msg)
+ if comments is not None:
+ warnings.warn(
+ "comments= is deprecated; use "
+ "FormatOptions(normalize_comments=...) instead",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ return FormatOptions(normalize_comments=comments)
+ if options is None:
+ return _DEFAULT_FORMAT_OPTIONS
+ return options
+
+
# ---------------------------------------------------------------------------
# Comment text + line cleanup
# ---------------------------------------------------------------------------
@@ -150,7 +210,7 @@ def _canon_leading(
*,
nl: str,
target_blanks: int | None,
- comments: bool,
+ options: FormatOptions,
) -> None:
"""Rewrite ``slot.leading`` to canonical form.
@@ -177,7 +237,7 @@ def _canon_leading(
middle_t = Trivia([p for line in middle for p in line])
retarget_trivia_newlines(middle_t, nl)
- _canon_trivia_text(middle_t, comments=comments)
+ _canon_trivia_text(middle_t, comments=options.normalize_comments)
# Preamble/subtree boundaries keep authored head gaps; attached
# comment blocks clamp to 0/1 so separation intent survives.
@@ -197,20 +257,27 @@ def _canon_leading(
# ---------------------------------------------------------------------------
-def _canon_eol(eol: EolTrivia, *, nl: str, comments: bool) -> None:
+def _canon_eol(eol: EolTrivia, *, nl: str, options: FormatOptions) -> None:
"""Normalise an :class:`EolTrivia`.
Retargets newline to ``nl`` (leaving ``None`` for a no-final-newline
- tail), canonicalises optional comment text, and keeps exactly one
- separator space before comments.
+ tail), canonicalises optional comment text, and applies the configured
+ separator before comments.
"""
retarget_eol_newline(eol, nl)
if eol.comment is None:
eol.trailing_ws = None
return
- if comments:
+ if options.normalize_comments:
eol.comment.text = _canon_comment_text(eol.comment.text)
- eol.trailing_ws = WhitespaceNode(" ")
+ eol.trailing_ws = _comment_separator(options)
+
+
+def _comment_separator(options: FormatOptions) -> WhitespaceNode | None:
+ """Return the configured whitespace before an EOL comment."""
+ if options.eol_comment_spaces == 0:
+ return None
+ return WhitespaceNode(" " * options.eol_comment_spaces)
# ---------------------------------------------------------------------------
@@ -230,7 +297,7 @@ def _canon_key_equals(node: KVSlot | InlineTableEntry) -> None:
# ---------------------------------------------------------------------------
-def _canon_kv_slot(slot: KVSlot, *, nl: str, comments: bool) -> None:
+def _canon_kv_slot(slot: KVSlot, *, nl: str, options: FormatOptions) -> None:
"""Normalise a KV slot's body (key/eq/value/eol).
Leading is handled separately by :func:`_canon_leading` so that
@@ -238,15 +305,17 @@ def _canon_kv_slot(slot: KVSlot, *, nl: str, comments: bool) -> None:
level.
"""
_canon_key_equals(slot)
- _canon_value(slot.value, nl=nl, comments=comments)
- _canon_eol(slot.eol, nl=nl, comments=comments)
+ _canon_value(slot.value, nl=nl, options=options)
+ _canon_eol(slot.eol, nl=nl, options=options)
-def _canon_header_slot(slot: StructuralHeaderSlot, *, nl: str, comments: bool) -> None:
+def _canon_header_slot(
+ slot: StructuralHeaderSlot, *, nl: str, options: FormatOptions
+) -> None:
slot.inner_pre = ""
slot.inner_post = ""
slot.key_seps = ["."] * (len(slot.key_parts) - 1)
- _canon_eol(slot.eol, nl=nl, comments=comments)
+ _canon_eol(slot.eol, nl=nl, options=options)
# ---------------------------------------------------------------------------
@@ -258,25 +327,29 @@ def _canon_inline_value(
v: ArrayValue | InlineTableValue,
*,
nl: str,
- comments: bool,
+ options: FormatOptions,
parent_indent: str = "",
) -> None:
"""Canonicalise inline array/table layout while preserving shape."""
items = v.items
multi = v.is_multiline()
- item_indent = parent_indent + " " if multi else parent_indent
+ item_indent = parent_indent + (" " * options.indent) if multi else parent_indent
for it in items:
if isinstance(it, InlineTableEntry):
_canon_key_equals(it)
- _canon_value(it.value, nl=nl, comments=comments, parent_indent=item_indent)
+ _canon_value(it.value, nl=nl, options=options, parent_indent=item_indent)
if not multi:
_canon_single_line_inline(v)
return
_canon_multiline_shape(
- v, nl=nl, comments=comments, item_indent=item_indent, outer_indent=parent_indent
+ v,
+ nl=nl,
+ options=options,
+ item_indent=item_indent,
+ outer_indent=parent_indent,
)
@@ -284,7 +357,7 @@ def _canon_multiline_shape(
v: ArrayValue | InlineTableValue,
*,
nl: str,
- comments: bool,
+ options: FormatOptions,
item_indent: str,
outer_indent: str,
) -> None:
@@ -296,7 +369,12 @@ def _canon_multiline_shape(
produces only empty/single-space trivia.
"""
items = v.items
- last_line_open = _canon_multi_line_items(items, nl=nl, indent=item_indent)
+ last_line_open = _canon_multi_line_items(
+ items,
+ nl=nl,
+ indent=item_indent,
+ options=options,
+ )
if items:
head_eol, _ = split_eol_section(v.header_trivia)
_, head_above = split_above_block(v.header_trivia)
@@ -335,7 +413,7 @@ def _canon_multiline_shape(
_finalise_inline_trivia(
v,
nl=nl,
- comments=comments,
+ options=options,
item_indent=item_indent,
final_first_line_is_eol=final_eol_first,
)
@@ -353,8 +431,8 @@ def _compose_pad(
Layout is ``head_eol`` (already terminated when non-empty), optional
structural ``\n``, above-block, then trailing indent. Skip the
- structural newline when ``head_eol`` or upstream
- ``post_comma_trivia`` already closed the line.
+ structural newline when ``head_eol`` or the upstream item EOL channel
+ already closed the line.
"""
pieces: list[TriviaPiece] = list(head_eol.pieces)
if not head_eol.pieces and not line_already_open:
@@ -393,7 +471,7 @@ def _finalise_inline_trivia(
v: ArrayValue | InlineTableValue,
*,
nl: str,
- comments: bool,
+ options: FormatOptions,
item_indent: str = "",
final_first_line_is_eol: bool = False,
) -> None:
@@ -411,13 +489,13 @@ def _finalise_inline_trivia(
retarget_trivia_newlines(v.final_trivia, nl)
_canon_trivia_text(
v.header_trivia,
- comments=comments,
+ comments=options.normalize_comments,
comment_indent=item_indent,
first_line_is_eol=True,
)
_canon_trivia_text(
v.final_trivia,
- comments=comments,
+ comments=options.normalize_comments,
comment_indent=item_indent,
first_line_is_eol=final_first_line_is_eol,
)
@@ -425,13 +503,23 @@ def _finalise_inline_trivia(
retarget_trivia_newlines(it.leading, nl)
retarget_trivia_newlines(it.trailing, nl)
retarget_trivia_newlines(it.post_comma_trivia, nl)
- _canon_trivia_text(it.leading, comments=comments, comment_indent=item_indent)
+ _canon_trivia_text(
+ it.leading,
+ comments=options.normalize_comments,
+ comment_indent=item_indent,
+ )
# ``trailing`` and ``post_comma_trivia`` are EOL contexts:
# the space before ``#`` is the structural separator the
- # callers have already canonicalised to one space.
- _canon_trivia_text(it.trailing, comments=comments, strip_pre_comment_ws=False)
+ # caller has already canonicalised.
+ _canon_trivia_text(
+ it.trailing,
+ comments=options.normalize_comments,
+ strip_pre_comment_ws=False,
+ )
_canon_trivia_text(
- it.post_comma_trivia, comments=comments, strip_pre_comment_ws=False
+ it.post_comma_trivia,
+ comments=options.normalize_comments,
+ strip_pre_comment_ws=False,
)
@@ -440,23 +528,25 @@ def _canon_multi_line_items(
*,
nl: str,
indent: str,
+ options: FormatOptions,
) -> bool:
r"""Canonicalise per-item trivia for a multi-line inline value.
- Returns whether the last item's ``post_comma_trivia`` closed its row,
- so the caller can avoid adding a duplicate ``final_trivia`` newline.
+ Returns whether the last item's EOL channel closed its row, so the caller
+ can avoid adding a duplicate ``final_trivia`` newline.
Item 0's structural pad lives in ``header_trivia``, so its leading is
empty. Later items keep their above-item comment block but get
canonical newline+indent, suppressed when the previous item's
- ``post_comma_trivia`` already closed the row.
+ upstream EOL channel already closed the row.
- All items use trailing-comma style. If an item lacked a comma, move
- its EOL comment from ``trailing`` to ``post_comma_trivia`` so the
- comma renders before the comment; then shrink ``post_comma_trivia``
- to an EOL section or empty.
+ Internal items and, when requested, the final item use commas. Any
+ EOL comment moves between ``trailing`` and ``post_comma_trivia`` as
+ the comma state changes; both channels are then reduced to the
+ canonical EOL section or empty.
"""
prev_line_open = False
+ last_index = len(items) - 1
for k, it in enumerate(items):
if k == 0:
it.leading = Trivia()
@@ -469,34 +559,56 @@ def _canon_multi_line_items(
trailing_indent=indent,
line_already_open=prev_line_open,
)
- # Synthesising a comma may shift the EOL row from ``trailing``
- # to ``post_comma_trivia``; the take/put pair preserves the
- # comment across any has_comma flip.
- eol = _take_eol(it)
- it.has_comma = True
+ # Changing comma state may shift comments between ``trailing``
+ # and ``post_comma_trivia``; collect both sides before clearing them.
+ comments = [
+ p
+ for trivia in (it.trailing, it.post_comma_trivia)
+ for p in trivia.pieces
+ if isinstance(p, CommentNode)
+ ]
+ it.has_comma = k < last_index or options.multiline_trailing_comma
it.trailing = Trivia()
- _put_eol(it, eol)
- prev_line_open = _canon_post_comma_trivia(it, nl=nl)
+ it.post_comma_trivia = Trivia()
+ prev_line_open = _canon_item_eol(
+ it,
+ comments,
+ nl=nl,
+ options=options,
+ indent=indent,
+ )
return prev_line_open
-def _canon_post_comma_trivia(item: CommaItem, *, nl: str) -> bool:
- r"""Canonicalise ``post_comma_trivia``; return True if the line was closed.
+def _canon_item_eol(
+ item: CommaItem,
+ comments: Sequence[CommentNode],
+ *,
+ nl: str,
+ options: FormatOptions,
+ indent: str,
+) -> bool:
+ r"""Write ``comments`` onto the item's EOL channel.
+
+ Returns whether they close the row.
- If the first non-whitespace piece is a comment it is preserved as
- an EOL row (``" # comment\\n"``) and we report the line as
- closed; otherwise the channel is cleared and the line stays open
- for the next item's leading pad to terminate.
+ The first comment stays on the item row; further comments occupy indented
+ lines below it. With no comments, the row stays open for the next item's
+ leading pad to terminate.
"""
- first = next(
- (p for p in item.post_comma_trivia.pieces if not isinstance(p, WhitespaceNode)),
- None,
- )
- if isinstance(first, CommentNode):
- item.post_comma_trivia = Trivia([WhitespaceNode(" "), first, NewlineNode(nl)])
- return True
- item.post_comma_trivia = Trivia()
- return False
+ if not comments:
+ return False
+ pieces: list[TriviaPiece] = []
+ for k, comment in enumerate(comments):
+ if k == 0:
+ separator = _comment_separator(options)
+ if separator is not None:
+ pieces.append(separator)
+ elif indent:
+ pieces.append(WhitespaceNode(indent))
+ pieces.extend((comment, NewlineNode(nl)))
+ item_eol_channel(item).pieces = pieces
+ return True
# ---------------------------------------------------------------------------
@@ -504,9 +616,15 @@ def _canon_post_comma_trivia(item: CommaItem, *, nl: str) -> bool:
# ---------------------------------------------------------------------------
-def _canon_value(v: Value, *, nl: str, comments: bool, parent_indent: str = "") -> None:
+def _canon_value(
+ v: Value,
+ *,
+ nl: str,
+ options: FormatOptions,
+ parent_indent: str = "",
+) -> None:
if isinstance(v, (ArrayValue, InlineTableValue)):
- _canon_inline_value(v, nl=nl, comments=comments, parent_indent=parent_indent)
+ _canon_inline_value(v, nl=nl, options=options, parent_indent=parent_indent)
# Other value kinds carry no formattable trivia.
@@ -526,11 +644,7 @@ def set_comma_value_multiline(
while inline tables keep their pad (``{ a = 1 }``).
"""
items = value.items
- outer_indent = ""
- if value.is_multiline() and value.final_trivia.pieces:
- final_piece = value.final_trivia.pieces[-1]
- if isinstance(final_piece, WhitespaceNode):
- outer_indent = final_piece.text
+ outer_indent = _closing_indent(value)
# The explicit single<->multi toggle is the one operation that can flip
# shape without removing an item; drop the memo so it recomputes.
value.reset_multiline_cache()
@@ -553,16 +667,46 @@ def set_comma_value_multiline(
_canon_single_line_inline(value)
return
for it in items:
- _canon_value(it.value, nl=nl, comments=True, parent_indent=indent)
+ _canon_value(
+ it.value,
+ nl=nl,
+ options=_DEFAULT_FORMAT_OPTIONS,
+ parent_indent=indent,
+ )
_canon_multiline_shape(
value,
nl=nl,
- comments=True,
+ options=_DEFAULT_FORMAT_OPTIONS,
item_indent=indent,
outer_indent=outer_indent,
)
+def _closing_indent(value: ArrayValue | InlineTableValue) -> str:
+ """Return the whitespace immediately before a multiline closing bracket."""
+ if not value.is_multiline() or not value.final_trivia.pieces:
+ return ""
+ final_piece = value.final_trivia.pieces[-1]
+ return final_piece.text if isinstance(final_piece, WhitespaceNode) else ""
+
+
+def format_inline_root(
+ value: ArrayValue | InlineTableValue,
+ *,
+ nl: str,
+ options: FormatOptions,
+) -> None:
+ """Canonicalise an inline array/table formatted on its own.
+
+ Uses the value's existing closing-bracket column as the base indent,
+ so formatting it in isolation doesn't reset its position relative to
+ its enclosing document.
+ """
+ _canon_inline_value(
+ value, nl=nl, options=options, parent_indent=_closing_indent(value)
+ )
+
+
# ---------------------------------------------------------------------------
# Subtree walk and orchestration
# ---------------------------------------------------------------------------
@@ -603,7 +747,7 @@ def format_subtree(
path: tuple[str, ...],
owner: AoTEntry | None,
nl: str,
- comments: bool,
+ options: FormatOptions,
) -> None:
"""Canonicalise every slot in the subtree rooted at ``path``.
@@ -628,14 +772,14 @@ def format_subtree(
if prev.eol.newline is None:
prev.eol.newline = NewlineNode(nl)
if isinstance(slot, KVSlot):
- _canon_kv_slot(slot, nl=nl, comments=comments)
+ _canon_kv_slot(slot, nl=nl, options=options)
elif isinstance(slot, StructuralHeaderSlot):
- _canon_header_slot(slot, nl=nl, comments=comments)
+ _canon_header_slot(slot, nl=nl, options=options)
if prev is None:
target: int | None = None
else:
target = 1 if isinstance(slot, StructuralHeaderSlot) else 0
- _canon_leading(slot, nl=nl, target_blanks=target, comments=comments)
+ _canon_leading(slot, nl=nl, target_blanks=target, options=options)
prev = slot
slot = slot._next # noqa: SLF001
@@ -644,7 +788,7 @@ def format_document_trailing(
trailing: Trivia,
*,
nl: str,
- comments: bool,
+ options: FormatOptions,
) -> None:
"""Canonicalise the trailing trivia of a :class:`Document`.
@@ -654,15 +798,19 @@ def format_document_trailing(
preamble/epilogue split is unaffected.
"""
retarget_trivia_newlines(trailing, nl)
- _canon_trivia_text(trailing, comments=comments)
+ _canon_trivia_text(trailing, comments=options.normalize_comments)
__all__ = [
+ "FormatOptions",
"_canon_header_slot",
"_canon_inline_value",
"_canon_kv_slot",
"_canon_leading",
+ "_closing_indent",
+ "_resolve_format_options",
"format_document_trailing",
+ "format_inline_root",
"format_subtree",
"set_comma_value_multiline",
]
diff --git a/tests/test_format.py b/tests/test_format.py
index b014806..72f2f69 100644
--- a/tests/test_format.py
+++ b/tests/test_format.py
@@ -2,6 +2,11 @@
from __future__ import annotations
+import warnings
+from dataclasses import FrozenInstanceError
+from inspect import Parameter, signature
+from typing import Any
+
import pytest
import tomli
@@ -12,7 +17,7 @@
def _roundtrip(src: str, *, comments: bool = True) -> str:
doc = tomlrt.loads(src)
- doc.format(comments=comments)
+ doc.format(options=tomlrt.FormatOptions(normalize_comments=comments))
return tomlrt.dumps(doc)
@@ -191,6 +196,106 @@ def test_multiline_array_preserved_shape() -> None:
""")
+def test_format_options_omit_multiline_trailing_comma() -> None:
+ src = td("""
+ items = [
+ 1,
+ 2,
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ options = tomlrt.FormatOptions(multiline_trailing_comma=False)
+ doc.format(options=options)
+ expected = td("""
+ items = [
+ 1,
+ 2
+ ]
+ """)
+ assert tomlrt.dumps(doc) == expected
+ assert tomli.loads(expected) == {"items": [1, 2]}
+ doc.format(options=options)
+ assert tomlrt.dumps(doc) == expected
+
+
+def test_no_trailing_comma_preserves_final_item_eol_comment() -> None:
+ src = td("""
+ items = [
+ 1,
+ 2, # final
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.array("items").format(
+ options=tomlrt.FormatOptions(multiline_trailing_comma=False)
+ )
+ assert tomlrt.dumps(doc) == td("""
+ items = [
+ 1,
+ 2 # final
+ ]
+ """)
+
+
+def test_no_trailing_comma_recurses_through_arrays_and_inline_tables() -> None:
+ src = td("""
+ outer = [
+ {
+ values = [
+ 1,
+ ],
+ table = {
+ value = 2,
+ },
+ },
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=tomlrt.FormatOptions(multiline_trailing_comma=False))
+ assert tomlrt.dumps(doc) == td("""
+ outer = [
+ {
+ values = [
+ 1
+ ],
+ table = {
+ value = 2
+ }
+ }
+ ]
+ """)
+
+
+def test_no_trailing_comma_preserves_empty_array_and_crlf() -> None:
+ src = td("""
+ items = [
+ ]
+ """).replace("\n", "\r\n")
+ doc = tomlrt.loads(src)
+ doc.format(options=tomlrt.FormatOptions(multiline_trailing_comma=False))
+ assert tomlrt.dumps(doc) == src
+
+
+def test_append_after_format_without_trailing_comma_preserves_style() -> None:
+ src = td("""
+ items = [
+ 1,
+ 2, # final
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ items = doc.array("items")
+ items.format(options=tomlrt.FormatOptions(multiline_trailing_comma=False))
+ items.append(3)
+ assert tomlrt.dumps(doc) == td("""
+ items = [
+ 1,
+ 2, # final
+ 3
+ ]
+ """)
+
+
def test_comma_first_array_keeps_comment() -> None:
# A comma-first layout parks the item's EOL comment in ``trailing``
# *before* the comma, even though ``has_comma`` is set. format()
@@ -225,6 +330,182 @@ def test_comma_first_inline_table_keeps_comment() -> None:
""")
+@pytest.mark.parametrize(
+ "options",
+ [
+ tomlrt.FormatOptions(multiline_trailing_comma=True),
+ tomlrt.FormatOptions(multiline_trailing_comma=False),
+ ],
+)
+def test_comma_first_array_keeps_post_comma_comment(
+ options: tomlrt.FormatOptions,
+) -> None:
+ src = td("""
+ a = [
+ 1
+ , # after comma
+ 2
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=options)
+ expected = (
+ td("""
+ a = [
+ 1, # after comma
+ 2,
+ ]
+ """)
+ if options.multiline_trailing_comma
+ else td("""
+ a = [
+ 1, # after comma
+ 2
+ ]
+ """)
+ )
+ assert tomlrt.dumps(doc) == expected
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ tomlrt.FormatOptions(multiline_trailing_comma=True),
+ tomlrt.FormatOptions(multiline_trailing_comma=False),
+ ],
+)
+def test_comma_first_inline_table_keeps_post_comma_comment(
+ options: tomlrt.FormatOptions,
+) -> None:
+ src = td("""
+ t = {
+ a = 1
+ , # after comma
+ b = 2
+ }
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=options)
+ expected = (
+ td("""
+ t = {
+ a = 1, # after comma
+ b = 2,
+ }
+ """)
+ if options.multiline_trailing_comma
+ else td("""
+ t = {
+ a = 1, # after comma
+ b = 2
+ }
+ """)
+ )
+ assert tomlrt.dumps(doc) == expected
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ tomlrt.FormatOptions(multiline_trailing_comma=True),
+ tomlrt.FormatOptions(multiline_trailing_comma=False),
+ ],
+)
+def test_comma_first_array_keeps_comments_on_both_sides(
+ options: tomlrt.FormatOptions,
+) -> None:
+ src = td("""
+ a = [
+ 1 # before comma
+ , # after comma
+ 2
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=options)
+ expected = (
+ td("""
+ a = [
+ 1, # before comma
+ # after comma
+ 2,
+ ]
+ """)
+ if options.multiline_trailing_comma
+ else td("""
+ a = [
+ 1, # before comma
+ # after comma
+ 2
+ ]
+ """)
+ )
+ assert tomlrt.dumps(doc) == expected
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ tomlrt.FormatOptions(multiline_trailing_comma=True),
+ tomlrt.FormatOptions(multiline_trailing_comma=False),
+ ],
+)
+def test_comma_first_inline_table_keeps_comments_on_both_sides(
+ options: tomlrt.FormatOptions,
+) -> None:
+ src = td("""
+ t = {
+ a = 1 # before comma
+ , # after comma
+ b = 2
+ }
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=options)
+ expected = (
+ td("""
+ t = {
+ a = 1, # before comma
+ # after comma
+ b = 2,
+ }
+ """)
+ if options.multiline_trailing_comma
+ else td("""
+ t = {
+ a = 1, # before comma
+ # after comma
+ b = 2
+ }
+ """)
+ )
+ assert tomlrt.dumps(doc) == expected
+
+
+def test_comma_first_multiple_comments_with_zero_indent() -> None:
+ src = td("""
+ a = [
+ 1 # before comma
+ , # after comma
+ 2
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(
+ options=tomlrt.FormatOptions(
+ indent=0,
+ multiline_trailing_comma=False,
+ )
+ )
+ assert tomlrt.dumps(doc) == td("""
+ a = [
+ 1, # before comma
+ # after comma
+ 2
+ ]
+ """)
+
+
def test_nested_multiline_array_indents_per_depth() -> None:
src = td("""
outer = [
@@ -325,6 +606,86 @@ def test_nested_table_in_multiline_array_indents() -> None:
""")
+def test_format_options_indent_recurses_through_inline_values() -> None:
+ src = td("""
+ outer = [
+ { nested = [
+ 1,
+ ] },
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=tomlrt.FormatOptions(indent=4))
+ assert tomlrt.dumps(doc) == td("""
+ outer = [
+ { nested = [
+ 1,
+ ] },
+ ]
+ """)
+
+
+def test_array_scoped_format_preserves_outer_indent_with_custom_step() -> None:
+ src = td("""
+ outer = [
+ { nested = [
+ 1,
+ ] },
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.array("outer").table(0).array("nested").format(
+ options=tomlrt.FormatOptions(indent=4)
+ )
+ assert tomlrt.dumps(doc) == td("""
+ outer = [
+ { nested = [
+ 1,
+ ] },
+ ]
+ """)
+
+
+def test_table_scoped_format_preserves_outer_indent_with_custom_step() -> None:
+ src = td("""
+ outer = [
+ { nested = {
+ value=1,
+ } },
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.array("outer").table(0).table("nested").format(
+ options=tomlrt.FormatOptions(indent=4)
+ )
+ assert tomlrt.dumps(doc) == td("""
+ outer = [
+ { nested = {
+ value = 1,
+ } },
+ ]
+ """)
+
+
+def test_format_options_zero_indent() -> None:
+ src = td("""
+ outer = [
+ [
+ 1,
+ ],
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=tomlrt.FormatOptions(indent=0))
+ assert tomlrt.dumps(doc) == td("""
+ outer = [
+ [
+ 1,
+ ],
+ ]
+ """)
+
+
def test_aot_recursion() -> None:
src = td("""
[[a]]
@@ -362,6 +723,229 @@ def test_comments_true_normalises_text() -> None:
""")
+def test_format_options_eol_comment_spaces_cover_supported_comments() -> None:
+ src = td("""
+ top=1#top
+ array = [
+ 1,#array
+ ]
+ table = {
+ value=1,#entry
+ }
+ [section]#header
+ x=1
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=tomlrt.FormatOptions(eol_comment_spaces=3))
+ assert tomlrt.dumps(doc) == td("""
+ top = 1 # top
+ array = [
+ 1, # array
+ ]
+ table = {
+ value = 1, # entry
+ }
+
+ [section] # header
+ x = 1
+ """)
+
+
+def test_format_options_zero_eol_comment_spaces() -> None:
+ src = td("""
+ key = 1 # key
+ values = [
+ 1, # item
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=tomlrt.FormatOptions(eol_comment_spaces=0))
+ assert tomlrt.dumps(doc) == td("""
+ key = 1# key
+ values = [
+ 1,# item
+ ]
+ """)
+
+
+def test_comment_spacing_is_independent_of_text_normalization() -> None:
+ src = td("""
+ key=1# text
+ values = [
+ 1,# item
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(
+ options=tomlrt.FormatOptions(
+ normalize_comments=False,
+ eol_comment_spaces=2,
+ )
+ )
+ assert tomlrt.dumps(doc) == td("""
+ key = 1 # text
+ values = [
+ 1, # item
+ ]
+ """)
+
+
+def test_eol_comment_spacing_preserves_opening_bracket_spacing() -> None:
+ src = td("""
+ values = [ # opening
+ 1, # item
+ ]
+ """)
+ doc = tomlrt.loads(src)
+ doc.format(options=tomlrt.FormatOptions(eol_comment_spaces=2))
+ assert tomlrt.dumps(doc) == td("""
+ values = [ # opening
+ 1, # item
+ ]
+ """)
+
+
+def test_eol_comment_spacing_preserves_crlf() -> None:
+ src = td("""
+ values = [
+ 1,# item
+ ]
+ """).replace("\n", "\r\n")
+ doc = tomlrt.loads(src)
+ doc.array("values").format(options=tomlrt.FormatOptions(eol_comment_spaces=2))
+ assert tomlrt.dumps(doc) == td("""
+ values = [
+ 1, # item
+ ]
+ """).replace("\n", "\r\n")
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ pytest.param(
+ {"indent": -1},
+ "indent must be non-negative",
+ id="indent",
+ ),
+ pytest.param(
+ {"eol_comment_spaces": -1},
+ "eol_comment_spaces must be non-negative",
+ id="eol-comment-spaces",
+ ),
+ ],
+)
+def test_format_options_rejects_negative_values(
+ kwargs: Any,
+ message: str,
+) -> None:
+ with pytest.raises(ValueError, match=message):
+ tomlrt.FormatOptions(**kwargs)
+
+
+def test_format_options_is_frozen_and_keyword_only() -> None:
+ options = tomlrt.FormatOptions()
+ assert options.normalize_comments is True
+ attribute = "normalize_comments"
+ with pytest.raises(FrozenInstanceError):
+ setattr(options, attribute, False)
+ parameters = signature(tomlrt.FormatOptions).parameters
+ assert all(p.kind is Parameter.KEYWORD_ONLY for p in parameters.values())
+
+
+def test_all_format_options_interact_without_changing_data() -> None:
+ src = td("""
+ root=1# root
+ outer = [
+ {
+ values = [
+ 1,
+ 2,# final
+ ],
+ table = {
+ value=3,# entry
+ },
+ },
+ ]
+ [section]# header
+ key=4
+ """)
+ doc = tomlrt.loads(src)
+ before = doc.to_dict()
+ options = tomlrt.FormatOptions(
+ normalize_comments=False,
+ indent=4,
+ eol_comment_spaces=2,
+ multiline_trailing_comma=False,
+ )
+ doc.format(options=options)
+ expected = td("""
+ root = 1 # root
+ outer = [
+ {
+ values = [
+ 1,
+ 2 # final
+ ],
+ table = {
+ value = 3 # entry
+ }
+ }
+ ]
+
+ [section] # header
+ key = 4
+ """)
+ assert tomlrt.dumps(doc) == expected
+ assert doc.to_dict() == before
+ assert tomli.loads(expected) == before
+ doc.format(options=options)
+ assert tomlrt.dumps(doc) == expected
+
+
+def test_legacy_comments_warns_at_caller_for_each_receiver() -> None:
+ doc = tomlrt.loads(
+ td("""
+ a = { x=1 }
+ b = [1,2 ]
+ """)
+ )
+ receivers = (doc, doc.table("a"), doc.array("b"))
+ for receiver in receivers:
+ with pytest.warns(
+ DeprecationWarning, match="comments= is deprecated"
+ ) as caught:
+ receiver.format(comments=False)
+ assert caught[0].filename == __file__
+
+
+def test_format_rejects_options_with_legacy_comments_without_mutating() -> None:
+ src = "a =1\n"
+ doc = tomlrt.loads(src)
+ with pytest.raises(ValueError, match="cannot specify both"):
+ doc.format(options=tomlrt.FormatOptions(), comments=False)
+ assert tomlrt.dumps(doc) == src
+
+
+def test_recursive_format_with_options_emits_no_deprecation_warning() -> None:
+ doc = tomlrt.loads(
+ td("""
+ a.x =1
+ [a.b]
+ y=2
+ """)
+ )
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ doc.table("a").format(options=tomlrt.FormatOptions())
+ assert caught == []
+ assert tomlrt.dumps(doc) == td("""
+ a.x = 1
+ [a.b]
+ y = 2
+ """)
+
+
def test_bare_hash_comment_stays() -> None:
assert _roundtrip("a = 1 #\n", comments=True) == "a = 1 #\n"
diff --git a/tests/test_hypothesis.py b/tests/test_hypothesis.py
index e5b1c2e..fe1fc96 100644
--- a/tests/test_hypothesis.py
+++ b/tests/test_hypothesis.py
@@ -13,7 +13,7 @@
import tomlrt
from _helpers import td
-from tomlrt import Document
+from tomlrt import Document, FormatOptions
pytestmark = pytest.mark.slow
@@ -247,6 +247,42 @@ def test_edge_cases_roundtrip(src: str) -> None:
assert tomlrt.dumps(tomlrt.loads(src)) == src
+_FORMAT_OPTIONS = st.builds(
+ FormatOptions,
+ normalize_comments=st.booleans(),
+ indent=st.integers(min_value=0, max_value=4),
+ eol_comment_spaces=st.integers(min_value=0, max_value=3),
+ multiline_trailing_comma=st.booleans(),
+)
+
+
+@given(options=_FORMAT_OPTIONS)
+@settings(max_examples=100, database=None)
+def test_format_options_preserve_data_and_are_idempotent(
+ options: FormatOptions,
+) -> None:
+ src = td("""
+ root = 1 # root
+ outer = [
+ {
+ values = [
+ 1,
+ 2, # final
+ ],
+ },
+ ]
+ """)
+ expected = tomli.loads(src)
+ doc = tomlrt.loads(src)
+ doc.format(options=options)
+ once = tomlrt.dumps(doc)
+ assert _deep_equal(doc.to_dict(), expected)
+ assert _deep_equal(tomli.loads(once), expected)
+ doc.format(options=options)
+ assert tomlrt.dumps(doc) == once
+ assert tomlrt.dumps(tomlrt.loads(once)) == once
+
+
# ---------------------------------------------------------------------------
# Comment-view round-trip: writing back what we read must be a no-op, and
# the rendered comment must read back as the value we wrote. These caught