Skip to content

Python: keep expression/statement wrappers type-correct after visits - #8341

Closed
kdelay wants to merge 5 commits into
openrewrite:mainfrom
kdelay:fix/issue-8322-statement-expression-wrapper
Closed

Python: keep expression/statement wrappers type-correct after visits#8341
kdelay wants to merge 5 commits into
openrewrite:mainfrom
kdelay:fix/issue-8322-statement-expression-wrapper

Conversation

@kdelay

@kdelay kdelay commented Jul 29, 2026

Copy link
Copy Markdown

Py.ExpressionStatement declares an Expression child and Py.StatementExpression a Statement child, but PythonVisitor rebuilt both wrappers with whatever the child visit returned. Python dataclasses do not enforce the annotation, so a visitor replacing the child with a node of the other kind (e.g. migrating yield from x to await x, where Py.Await is an Expression but not a Statement) silently produced StatementExpression(Await). The invalid tree only failed later, on the Java side of the RPC round trip, where PythonReceiver.visitStatementExpression casts the child to Statement and throws ClassCastException.

Fix (visitor.py)

  • visit_expression_statement / visit_statement_expression now check the visited child's kind. When it no longer fits the wrapper's declared type but fits the opposite one, the wrapper is swapped (ExpressionStatement <-> StatementExpression) instead of rebuilding an invalid node.
  • Nodes implementing both Expression and Statement keep their original wrapper. Both wrappers delegate prefix and markers to the child, so no formatting is lost.

Verification

Py.ExpressionStatement declares an Expression child and Py.StatementExpression
a Statement child, but PythonVisitor rebuilt both wrappers with whatever the
child visit returned. Python dataclasses do not enforce the annotation, so a
visitor that replaces the child with a node of the other kind (e.g. migrating
`yield from x` to `await x`, where Py.Await is an Expression but not a
Statement) silently produced StatementExpression(Await). The invalid tree only
failed later, on the Java side of the RPC round trip, where
PythonReceiver.visitStatementExpression casts the child to Statement and threw
ClassCastException.

Detect the kind change in visit_expression_statement / visit_statement_expression
and swap to the matching wrapper. Nodes implementing both Expression and
Statement keep their original wrapper, and both wrappers delegate prefix and
markers to the child, so no formatting is lost.

Verified: new regression tests fail before the fix (invalid wrappers for both
directions) and pass after; full rewrite-python suite green (1755 passed,
85 skipped).

Fixes openrewrite#8322
isinstance(None, Statement)/isinstance(None, Expression) is already False,
so the explicit None guard could never change the outcome. Reorder the two
remaining checks to read as the positive statement first.

@timtebeek timtebeek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for jumping in here @kdelay ! Unexpected and much appreciated. The change looks good to me, with a little polish we can have this go out today.

@github-project-automation github-project-automation Bot moved this from In Progress to Ready to Review in OpenRewrite Jul 29, 2026
@timtebeek
timtebeek marked this pull request as draft July 29, 2026 11:25
@timtebeek

Copy link
Copy Markdown
Member

Found some more gaps that require a closer look before we merge, and I'd like to get some colleagues involved as well.

What made you pick this up here? As I can see you're broadly doing contributions across a wide set of projects.

@timtebeek
timtebeek force-pushed the fix/issue-8322-statement-expression-wrapper branch from 89bc46a to 525db98 Compare July 29, 2026 11:35
Follow-up to the ExpressionStatement/StatementExpression normalization.
The previous guard only fired for a replacement that was exclusively the
opposite kind, which left three cases building invalid trees:

- A replacement that is both Expression and Statement (J.Assignment,
  J.MethodInvocation) stayed in a stale StatementExpression. The printer
  only treats Block/CompilationUnit/ExpressionStatement parents as regular
  assignments, so `yield 1` -> `a = 1` printed the walrus form `a := 1`,
  which is not valid in statement position.
- An already-wrapped replacement passed the guard (both wrappers are
  themselves Expression and Statement) and got wrapped a second time.
- A deleted child (visit returning None) fell through to replace(), leaving
  a wrapper holding None that crashed on the delegating markers property or
  silently emitted a function with an empty body.

Both methods now share one helper that applies the least wrapping making the
child usable as both an expression and a statement, and propagate None so the
enclosing block drops the statement.
ExpressionStatement stores neither prefix nor markers, exposing the wrapped
expression's instead. Without a replace() override, replace_if_changed rebuilds
only from init fields, so replace(prefix=...) and replace(markers=...) were
silently dropped: a pointless clone came back with the original values. This hit
every ExpressionStatement, including the ones the parser emits for `x: int`, so
auto-format and blank-line normalization no-oped on them and marker attachment
such as SearchResult vanished.

StatementExpression already carried this override. Give the behavior a name and
share it between the two wrappers rather than duplicating the logic.
@timtebeek
timtebeek force-pushed the fix/issue-8322-statement-expression-wrapper branch from 525db98 to b020b5f Compare July 29, 2026 11:38
@timtebeek timtebeek closed this Jul 30, 2026
@github-project-automation github-project-automation Bot moved this from Ready to Review to Done in OpenRewrite Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

ClassCastException: Py.Await wrapped in Py.StatementExpression cannot be cast to Statement on RPC receive

2 participants