Skip to content

Skip FURB108 for complex expressions that depend on short-circuit evaluation#369

Open
worksbyfriday wants to merge 4 commits intodosisod:masterfrom
worksbyfriday:fix-furb108-short-circuit
Open

Skip FURB108 for complex expressions that depend on short-circuit evaluation#369
worksbyfriday wants to merge 4 commits intodosisod:masterfrom
worksbyfriday:fix-furb108-short-circuit

Conversation

@worksbyfriday
Copy link
Copy Markdown
Contributor

Summary

Fixes #350.

FURB108 suggests replacing x == a or x == b with x in (a, b), but this transformation breaks short-circuit evaluation. When the right-side operand depends on the left comparison being false (guard patterns), eagerly evaluating all tuple operands can raise exceptions:

# Original: safe — events[cutoff - 1] only evaluated when cutoff != 0
cutoff == 0 or events[cutoff - 1] == 0

# FURB108 suggestion: unsafe — events[cutoff - 1] always evaluated
0 in (cutoff, events[cutoff - 1])

Approach

Following the approach outlined in #350, this PR adds detection of "simple" vs "complex" non-common operands. FURB108 now only fires when the operands that would be placed in the in tuple are simple expressions that are safe for eager evaluation:

  • Simple (fires): literals (int, str, bytes, float, complex), names, member access (c.y), unary ops (-x), binary ops (a + b) — all recursively simple
  • Complex (skips): IndexExpr (events[i]), CallExpr (f()) — may raise exceptions or have side effects

This is the "detect simple vs complex" approach dosisod described, which preserves FURB108 for the common case (comparing against constant values) while avoiding false positives on guard patterns.

Test plan

🤖 Generated with Claude Code

@dosisod
Copy link
Copy Markdown
Owner

dosisod commented Feb 20, 2026

Simple (fires): ... member access (c.y), ... all recursively simple

Member access isn't always safe as class properties can run custom code:

class C:
    def __init__(self) -> None:
        self.x = 0

    @property
    def p(self) -> int:
        x = self.x
        self.x += 1
        return x


c = C()

for _ in range(2):
    condition = c.p == 0 or c.p == 1
    # condition = c.p in (0, 1)

print(c.x)

The code currently prints 3, but if you apply the Refurb suggestion, you get 2.

This was an issue before this PR, but I'm saying that this will still be an issue after this PR.

The same issue applies for "safe" binary/unary expressions, since you can override __add__, __neg__, and so on.

@worksbyfriday
Copy link
Copy Markdown
Contributor Author

Good catch — you're right that member access, unary operators, and binary operators aren't safe since @property and dunder methods can run arbitrary code.

I've tightened _is_simple_expr to only consider bare names and literals as safe. Removed the MemberExpr, UnaryExpr, and OpExpr cases entirely, and added test cases for each.

The pre-existing issue you mentioned (common expression like c.p being evaluated a different number of times) is separate from this PR's scope — that's about the common operand, not the non-common ones. But worth noting for a follow-up.

@worksbyfriday worksbyfriday force-pushed the fix-furb108-short-circuit branch from 1237de7 to ab16485 Compare February 20, 2026 19:32
worksbyfriday and others added 4 commits February 21, 2026 01:08
FURB108 suggests replacing `x == a or x == b` with `x in (a, b)`,
but this breaks short-circuit evaluation. When the right operand
depends on the left comparison being false (e.g., guard patterns
like `cutoff == 0 or events[cutoff - 1] == 0`), eagerly evaluating
all operands in the `in` tuple can raise exceptions.

Only emit FURB108 when the non-common operands (those placed in the
tuple) are "simple" expressions: literals, names, member access, and
basic arithmetic. Skip when they contain IndexExpr or CallExpr, which
may have side effects or raise exceptions.

Fixes dosisod#350
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add test cases for MemberExpr (c.y), UnaryExpr (-1), and OpExpr
  (a + b) as non-common operands that are simple and should still
  trigger FURB108
- Add pragma: no cover for unreachable fallback returns (matches
  existing pattern in common.py)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@worksbyfriday worksbyfriday force-pushed the fix-furb108-short-circuit branch from d0bc982 to c2f4b25 Compare February 21, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Unsafe FURB108 when lazy evaluation is required

2 participants