Skip to content

Python: move unary operator spacing into SpacesVisitor - #8543

Open
knutwannheden wants to merge 3 commits into
mainfrom
python-unary-spacing-layering
Open

Python: move unary operator spacing into SpacesVisitor#8543
knutwannheden wants to merge 3 commits into
mainfrom
python-unary-spacing-layering

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Python: MinimumViableSpacing to add spaces before not #8540 taught MinimumViableSpacingVisitor to put a space between not and its operand whenever the operand's prefix was empty. That space is a style decision rather than a syntactic requirement — not(x), not[1] and not'x' are all legal Python — and MinimumViableSpacingVisitor runs over the whole compilation unit inside AutoFormat, so auto-formatting rewrote not(x) to not (x) in files no recipe had touched, with nothing downstream reconciling it.

The Java formatter already draws this line: AutoFormatVisitor runs MinimumViableSpacingVisitor and then SpacesVisitor, and SpacesVisitor.visitUnary normalizes the operand prefix per style. Python had neither half — SpacesVisitor has no visitUnary, so whatever the minimum-spacing pass produced was final.

The same pass was also missing the other side of the problem. A recipe that wraps an assert condition in not without transferring the condition's prefix printed assertnot x, and auto-format left it that way.

Examples

SpacesVisitor now owns unary spacing, which for Python means not always gets a separator and +, - and ~ are tightened — what PEP 8, black and PyCharm all produce:

assert not(x)   # -> assert not (x)
y = - z         # -> y = -z

MinimumViableSpacingVisitor adds a space only where a keyword and the expression that follows it would lex as a single token, so on its own it leaves both of those alone. Given a recipe that builds not with empty prefixes, it now produces:

assert x        # -> assert not x
assert (x)      # -> assert not(x)
assert 'x'      # -> assert not'x'
assert f'{x}'   # -> assert not f'{x}'

The last two differ because the f-string's source starts with an identifier character.

Summary

  • SpacesVisitor.visit_unary normalizes the operand prefix: one space after not, none after +, - and ~. No new SpacesStyle field — Python's SpacesStyle.AroundOperators mirrors PyCharm's Python settings, which expose no unary option, and and/or already hardcode their separator the same way.
  • MinimumViableSpacingVisitor decides by printing the expression and testing its first character, so the rule tracks what is actually emitted rather than a list of node types. The empty-prefix guard runs first, so parsed code essentially never reaches the print call.
  • That rule is applied to the expression following assert, return, and/or, in/not in/is/is not, and both the if and else of a conditional expression.

if and while conditions need nothing: they are wrapped in ControlParentheses, which keeps the space after the keyword when a recipe replaces the inner tree.

Two things are deliberately left out. The mirror direction — an expression immediately before a keyword, e.g. a if c if a recipe cleared padding.true_part.before — needs the last printed character of the left side and has no failing case yet. So do the remaining keyword parents (del, raise, yield, await, lambda, global, import/as), each a few lines with the helper now in place.

Test plan

  • tests/python/all/format/spaces_test.py — new file covering not gaining a space, collapsing not x, and tightening - z and ~ z
  • tests/python/all/format/minimum_viable_spacing_test.pynot(x) and not[1] unchanged; generated not separated from an identifier and an f-string but not from (, [ or a string literal; generated operands separated from assert, return, and, in and the if/else of a conditional expression
  • Each new test verified to fail before the corresponding change
  • pytest tests --ignore=tests/rpc — 1933 passed, 61 skipped

`MinimumViableSpacingVisitor` added a space between `not` and its operand
whenever the operand prefix was empty, which is a style decision rather than
a syntactic requirement: `not(x)`, `not[1]` and `not'x'` are all legal. That
made auto-format rewrite `not(x)` to `not (x)` in files no recipe had touched,
with nothing downstream reconciling it.

`SpacesVisitor` now owns unary spacing, matching the Java formatter's split
between the two visitors. `not` always gets a separator and the punctuation
operators are tightened, which is what PEP 8, black and PyCharm produce; the
Python `SpacesStyle` gains no new field, mirroring PyCharm's Python settings,
which expose no unary option.

`MinimumViableSpacingVisitor` now adds a space only where a keyword and the
following expression would lex as a single token, and applies that rule to the
expression side of `assert`, `return`, `and`/`or` and the `if`/`else` of a
conditional expression as well. A recipe that wraps an `assert` condition in
`not` without transferring the condition's prefix previously printed
`assertnot x`.
`in`, `not in`, `is` and `is not` are keyword operators on `Py.Binary`, so a
right operand with an empty prefix prints as `x iny`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant