Skip to content

[RFC][formatter] Prefer vertical alignment of arguments #24611

@XuehaiPan

Description

@XuehaiPan

Summary

As per #24572 (comment), propose removing the compact multi-line layout (Layout 2) where the formatter packs all arguments on a single indented line. Expressions would go directly from single-line (Layout 1) to one-per-line with trailing comma (Layout 3).

If adopted as the default, this also resolves the long-standing COM812 formatter incompatibility (#9216).

Current formatter behavior

The formatter produces three layouts for comma-separated content:

# Layout 1: single line (fits within line width)
result = func(a, b, c)

# Layout 2: compact multi-line (all args on one indented line, no trailing comma)
result = func(
    a, b, c, d, e, f, g, h
)

# Layout 3: one-per-line (with trailing comma)
result = func(
    a,
    b,
    c,
    d,
    e,
    f,
    g,
    h,
)

This proposal removes Layout 2. Expressions that don't fit on a single line would always use Layout 3.


Motivation

1. Readability

Layout 2 can be misleading at a glance — it's ambiguous whether the expression takes one argument or many:

# Layout 2 — at a glance, this looks like a single argument
result = func(
    this_function_actually(takes, two), parameters
)

# Layout 3 — structure is immediately clear
result = func(
    this_function_actually(takes, two),
    parameters,
)

This was raised independently by multiple users:

2. COM812 compatibility

The formatter and COM812 currently conflict because Layout 2 has no trailing comma:

# Formatter produces Layout 2
def args(
    aaaaaaaa, bbbbbbbbb, cccccccccc, ddddddddd, eeeeeeee, ffffff, gggggggggggg, hhhh
):
    pass

COM812 flags this (newline before ) without trailing comma), adds a comma, and the formatter re-expands to Layout 3 — requiring multiple ruff formatruff check --fixruff format passes to converge. Users must disable COM812 entirely to avoid this.

Without Layout 2, the formatter would always produce Layout 3 (with trailing comma) for expressions that don't fit on a single line. COM812 and the formatter would converge in a single ruff format pass.

3. Diff-friendliness

Layout 2 lacks a trailing comma, which means adding or removing an argument always touches the existing last line:

 # Layout 2 — no trailing comma, 2 lines changed to add an argument
 result = some_function(
-    first_argument, second_argument, third_argument, fourth_argument
+    first_argument, second_argument, third_argument, fourth_argument, fifth_argument
 )

Layout 3 with trailing comma keeps diffs minimal — only the new line is added:

 # Layout 3 with trailing comma — 1 line added
 result = some_function(
     first_argument,
     second_argument,
     third_argument,
     fourth_argument,
+    fifth_argument,
 )

This is the core purpose of COM812 — reducing diff noise when parameters change. Layout 2 directly undermines this goal.

4. pre-commit and CI usability

pre-commit expects each hook to be idempotent in a single pass. The current ruff format + COM812 combination fundamentally requires multiple passes:

  1. ruff format produces Layout 2 (no trailing comma)
  2. ruff check --fix adds trailing comma (COM812)
  3. ruff format re-expands to Layout 3 (trailing comma forces one-per-line)

This means pre-commit hooks always report changes on the first run, even when the final state is identical to the original. Users resort to multi-pass workarounds:

# Workaround: run format → check → format in a loop
- repo: local
  hooks:
    - id: ruff
      entry: bash -c '
        ruff format "$@";
        for i in 1 2 3; do
          ruff check --fix "$@";
          ruff format "$@";
        done;
        ruff check "$@" && ruff format --check "$@"
        ' --
      types: [python]

The multi-pass workaround also introduces potential bugs: each intermediate write to disk can trigger file watchers, invalidate caches, and cause stale reads in AI coding agents and IDE language servers that monitor file changes. When running pre-commit run --all-files, the loop touches every Python file in the repo multiple times even when the final state is identical to the original.

Without Layout 2, ruff format alone produces COM-compliant output. A single ruff format pass is sufficient — no ruff check --fix needed, no loops, no workarounds:

# Clean: single-pass hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
  hooks:
    - id: ruff-check
      args: [--fix, --exit-non-zero-on-fix]
    - id: ruff-format
      args: [--exit-non-zero-on-format]

5. AI-generated code

AI coding agents frequently produce over-wrapped expressions. With Layout 2, the formatter can't normalize these to a consistent style in one pass — the trailing comma presence or absence changes the output:

# AI-generated (trailing comma prevents collapsing in "respect" mode)
result = func(
    arg1,
    arg2,
)

# Formatter can't collapse this to `func(arg1, arg2)` because the
# trailing comma signals "keep expanded"

Without Layout 2, the formatter's behavior becomes more deterministic — expressions either fit on one line (Layout 1) or expand to one-per-line (Layout 3).

6. Consistency with other constructs

Lists, dicts, sets, and imports already go directly from Layout 1 to Layout 3 — they don't have a Layout 2 intermediate form. Only function calls, function definitions, pattern arguments, and subscript tuples use the compact layout (via an inner group() wrapper). Removing it would make all comma-separated constructs behave consistently.


Affected constructs

Layout 2 currently applies to:

  • Function call arguments
  • Function definition parameters
  • Match pattern arguments
  • Subscript tuples (e.g., matrix[a, b, c])

Lists, dicts, sets, imports, type parameters, with statements, and other comma-separated constructs already go directly from Layout 1 to Layout 3.


Possible solutions

Four magic-trailing-comma modes and their behavior:

Behavior "respect" "ignore" "force" "normalize"
Trailing comma as expansion signal Yes No Yes No
Add comma to hanging multi-line If formats to one-per-line If formats to one-per-line Always If doesn't fit in single-line
Add comma to one-per-line If formats to one-per-line If formats to one-per-line Always If doesn't fit in single-line
Remove comma from single-line No (expands to one-per-line) If fits in single-line No (expands to one-per-line) If fits in single-line
Remove comma from one-per-line No If formats to single-line or hanging multi-line No If fits in single-line
Remove Layout 2 No No Yes (consequence of adding comma) Yes (explicit style spec)
COM812 safe No No Yes Yes
Status Current default skip-magic-trailing-comma = true New (#24609) New (#24572)

All four modes on the compact multi-line case:

# Input — Layout 2
result = some_function(
    first_argument_name, second_argument_name, third_argument_name, fourth_argument_name
)

# respect — stays Layout 2 (no trailing comma signal)
result = some_function(
    first_argument_name, second_argument_name, third_argument_name, fourth_argument_name
)

# ignore — stays Layout 2
result = some_function(
    first_argument_name, second_argument_name, third_argument_name, fourth_argument_name
)

# force — Layout 3 (trailing comma added → one-per-line)
result = some_function(
    first_argument_name,
    second_argument_name,
    third_argument_name,
    fourth_argument_name,
)

# normalize — Layout 3 (same result for this case)
result = some_function(
    first_argument_name,
    second_argument_name,
    third_argument_name,
    fourth_argument_name,
)

All four modes on over-wrapped short expressions with trailing comma:

# Input — AI-generated, fits on one line
result = func(
    arg1,
    arg2,
)

# respect — stays expanded (trailing comma is "magic")
result = func(
    arg1,
    arg2,
)

# ignore — collapses (trailing comma ignored)
result = func(arg1, arg2)

# force — stays expanded (trailing comma is still "magic")
result = func(
    arg1,
    arg2,
)

# normalize — collapses (trailing comma ignored + comma removed)
result = func(arg1, arg2)

"force" and "normalize" both resolve the COM812 conflict but differ on trailing comma semantics:

  • "force" respects existing trailing commas — the user's intent to keep expressions expanded is preserved
  • "normalize" ignores existing trailing commas — the formatter fully owns layout decisions based on line width

Either could be gated behind preview, edition = "next", or made the default. They are also compatible with removing Layout 2 globally — if Layout 2 is removed for everyone, "force" becomes equivalent to "respect" and "normalize" becomes equivalent to "ignore" for the layout, with trailing comma addition as the only remaining difference.


Open questions

Per #24572 (comment):

  1. Style: Is removing Layout 2 (always using Layout 3 for multi-line) a formatting style we want to support?
  2. Default: Should this become the default, and if so, how do we get there?
    • Direct change (breaking — reformats all existing code)
    • preview flag (opt-in, may stabilize later)
    • edition = "next" (opt-in, stable within the edition, requires designing the edition mechanism)
  3. Configurability: If it shouldn't become the default, how should it be configurable? And should the config live in [format] or [lint.flake8-commas]?
  4. Black compatibility: Layout 2 matches Black's line-wrapping behavior. Removing it is a deliberate deviation. Is this acceptable?
  5. Ecosystem impact: How much code would be reformatted? [WIP] Prefer vertical alignment of arguments #23083 may have ecosystem data from its exploration.
  6. Scope: Should this apply to all four affected constructs (calls, defs, patterns, subscripts) equally, or should some keep Layout 2?
  7. Relationship to COM812: If Layout 2 is removed globally, should the COM812 incompatibility warning be removed? Should COM812 itself be reconsidered (since the formatter would handle trailing commas)?

Prior work

cc @MichaReiser @dylwil3 @charliermarsh

Metadata

Metadata

Assignees

No one assigned

    Labels

    formatterRelated to the formatterstyleHow should formatted code look

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions