ruff FURB171
labelled unknown
Analysis Nemotron 3 Super
Flags membership tests (`in`/`not in`) where the right operand is a literal container containing exactly one element.
Fix: Replaces `x in [y]` with `x == y` and `x not in [y]` with `x != y`, preserving the left operand and the single item from the container literal.
12 hazards
- For string containers like `x in "a"`, the rewrite to `x == "a"` fails to match the empty string, changing behavior when `x` is `""`.
- For `NaN` values, `nan in [nan]` evaluates to `True` due to identity-based membership, but `nan == nan` is `False`, altering the result.
- If the container is a view like `d.keys()` where `d` is later mutated, the rewrite evaluates the view earlier, potentially capturing a stale state.
- When the single item is an expression with side effects (e.g., `x in [f()]`), the rewrite may evaluate it once instead of preserving the original evaluation context.
- If the left operand `x` is a complex expression with side effects (e.g., `g() in [1]`), the rewrite duplicates its evaluation, causing unintended repeated side effects.
- The fix removes any comments located between the `in`/`not in` operator and the container brackets, altering source code formatting.
- For byte strings like `x in b'\x00'`, the rewrite to `x == b'\x00'[0]` incorrectly compares to an integer instead of a byte string element.
- When the container holds a mutable object (e.g., `x in [[]]`), the rewrite uses `==` which may trigger `__eq__` overrides, differing from identity-based `in` semantics.
- If the single item is a placeholder like `...` (Ellipsis), the rewrite to `x == ...` is syntactically valid but semantically opaque and not intended for direct comparison.
- In `not in` contexts, rewriting `x not in [y]` to `x != y` fails when `x` and `y` are of incomparable types that still define `__contains__` (e.g., mixed types in custom containers).
- The rewrite assumes the container is literally a single-element display; it does not apply to variables or function calls, so misapplication could occur if the linter is bypassed.
- For containers formed via unpacking like `x in [*[y]]`, the fix may not trigger or may produce invalid syntax if applied incorrectly.
Programs
| id | result | title | hazard |
|---|---|---|---|
| r1-p001 | fired, no fix | String container length mismatch | For string containers like `x in "a"`, the rewrite to `x == "a"` fails to match the empty string, changing behavior when `x` is `""`. |
| r1-p002 | fired, no fix | Keyword-only argument evaluation timing | keyword-only, positional-only, *args/**kwargs forwarding, defaults evaluated once |
| r1-p003 | fired, no fix | Star-import and __all__ interaction | star-imports, module-level side effects, __all__ |
| r1-p004 | silent prediction off | Byte string element vs container | For byte strings like `x in b'\x00'`, the rewrite to `x == b'\x00'[0]` incorrectly compares to an integer instead of a byte string element. |
| r1-p005 | silent prediction off | Mutable default argument aliasing | mutable default arguments and aliasing after a rewrite |
| r1-p006 | fired, no fix | Ellipsis placeholder comparison | If the single item is a placeholder like `...` (Ellipsis), the rewrite to `x == ...` is syntactically valid but semantically opaque and not intended for direct comparison. |
| r1-p007 | fired, no fix | Class attribute vs instance attribute with property | class attributes vs instance attributes, descriptors, properties with side effects |
| r1-p008 | silent | Unpacking container literal | For containers formed via unpacking like `x in [*[y]]`, the fix may not trigger or may produce invalid syntax if applied incorrectly. |
| r2-p001 | fired, no fix | Type annotation evaluated at runtime | type annotations evaluated at runtime, typing-only imports |
| r2-p002 | silent prediction off | String container matching empty string | For string containers like `x in "a"`, the rewrite to `x == "a"` fails to match the empty string, changing behavior when `x` is `"". |
| r2-p003 | silent prediction off | String formatting and repr/str edge cases | string formatting edge cases: %, format, f-strings, repr vs str, nested quotes |
| r2-p004 | fired, no fix | Evaluation order and side effects | evaluation order and side effects when expressions are merged, duplicated or deleted |
| r2-p005 | fired, no fix | NaN identity vs equality | For `NaN` values, `nan in [nan]` evaluates to `True` due to identity-based membership, but `nan == nan` is `False`, altering the result. |
| r2-p006 | fired, no fix | Mutable object __eq__ override | When the container holds a mutable object (e.g., `x in [[]]`), the rewrite uses `==` which may trigger `__eq__` overrides, differing from identity-based `in` semantics. |
| r2-p007 | silent prediction off | Not in with incomparable types | In `not in` contexts, rewriting `x not in [y]` to `x != y` fails when `x` and `y` are of incomparable types that still define `__contains__` (e.g., mixed types in custom containers). |
| r2-p008 | fired, no fix | Truthy/falsy and __bool__/__len__ | truthiness of custom __bool__/__len__, None vs falsy, 'is' vs '==' |
Sources
r1-p001 · String container length mismatch
1def f():
2 xs = ['', 'a']
3 out = []
4 for x in xs:
5 # Rule triggers here: x in "a"
6 out.append(x in "a")
7 return out
8print(f())
[True, True]
r1-p002 · Keyword-only argument evaluation timing
1def g(*, arg=[0]):
2 arg.append(1)
3 return arg
4# Rule triggers on next line: checks if result contains first element
5lst = g()
6print(lst[0] in lst) # before: True; after fix: compares lst[0] == lst[0]? still True but side effect changed
7# To see difference, call again:
8lst2 = g()
9print(lst2[0] in lst2) # now lst2 is [0,1,1]; lst2[0]==0; 0 in [0,1,1] -> True; 0 == lst2[0] -> True
10# But we need behavioral diff -- use containment vs equality on mutated list
11# Instead: test if the *same* list object is in a list of lists
12container = [[0], [0,1]]
13el = [0]
14print(el in container) # False
15el.append(1)
16print(el in container) # True
17# Not triggering rule. Let's do proper hazard.
18# Redo: function with keyword default that mutates, and we test membership in that default.
19def h(*, x=[]):
20 x.append(1)
21 return x
22# Rule triggers: check if 0 in h()
23print(0 in h()) # First call: h() returns [1]; 0 in [1] -> False
24print(0 in h()) # Second: [1,1]; still False
25# No diff. Use identity.
26print([] in [h()]) # First: [[]] in [[1]]? False; after fix: [] == h()[0]? [] == 1? False
27# Still both False. Need where containment differs from equality.
28# Use NaN.
29import math
30nan = float('nan')
31print(nan in [nan]) # True
32print(nan == nan) # False
33# Rule would trigger on nan in [nan] -> rewrite to nan == nan -> changes True to False
34print(nan in [nan]) # Expected True
35try:
36 print(nan == nan)
37except Exception as e:
38 print(type(e).__name__, e)
True True True True False False False True False True False
r1-p003 · Star-import and __all__ interaction
1# Simulate module side effects via exec
2code = '''
3__all__ = ['A']
4A = 1
5B = 2
6'''
7exec(code, globals())
8# Rule triggers: check if 'A' in __all__
9print('A' in __all__) # True
10# After fix: 'A' == __all__[0] -> 'A' == 'A' -> True
11# Same. Need where __all__ is not a list literal? But rule needs literal.
12# Make __all__ built via side effect that we test later.
13global_list = []
14def side_effect():
15 global_list.append('x')
16 return global_list
17# __all__ not involved. Use: if we could do from mod import *, but can't.
18# Instead: use that __all__ affects what star-import sees, but we can't do star-import.
19# Hazard: if module has side effect in __all__ assignment, and we reload.
20# Since we can't reload, simulate: two phases.
21phase1_all = ['A']
22# Simulate importing * from a module with __all__ = phase1_all
23locals_copy = {}
24for name in phase1_all:
25 locals_copy[name] = globals().get(name, None)
26# Now change phase1_all
27phase1_all.append('B')
28# If we had done star-import earlier, we wouldn't get B.
29# Rule triggers: checking membership in phase1_all during import simulation
30print('B' in phase1_all) # Now True
31# After fix: 'B' == phase1_all[0] -> 'B' == 'A' -> False
32# So if rule fired during first check, it'd have used wrong value.
33# But we need the rule to fire on a literal. Make phase1_all a literal? Can't.
34# Use: at point of rule firing, the container is a literal, but later mutated.
35# Container must be literal for rule to fire. So:
36print('B' in ['A']) # Rule triggers here: literal ['A']
37# At this point, evaluates to False
38# After fix: 'B' == ['A'][0] -> 'B' == 'A' -> False
39# Same. Need where mutation happens between two checks.
40# Store the literal in a variable? No, rule only fires on literals.
41# So: two literals, same content, but we mutate a variable named same? Not possible.
42# Give up and use: the literal is evaluated each time, but if we could mutate it... we can't.
43# Instead: hazard is that fixing to equality breaks if the item has side effect when accessed twice.
44item = ['side']
45class Side:
46 def __eq__(self, other):
47 print('EQ')
48 return False
49 def __hash__(self):
50 return 0
51s = Side()
52print(s in [s]) # Rule triggers: calls __contains__ -> iterates, calls __eq__ once? Actually list.__contains__ does == for each item.
53# So: iterates over [s], calls s.__eq__(s) -> prints EQ, returns ?
54# After fix: s == [s][0] -> calls s.__eq__(s) again -> prints EQ again.
55# So if we count EQ calls, differ.
56counter = 0
57class Side2:
58 def __eq__(self, other):
59 nonlocal counter
60 counter += 1
61 return other is self
62 def __hash__(self):
63 return id(self)
64s2 = Side2()
65print(s2 in [s2]) # Should trigger rule; counter +=1
66print(f'EQ calls after in: {counter}')
67counter = 0
68print(s2 == [s2][0]) # After fix version; counter +=1
69print(f'EQ calls after == : {counter}')
70# Both 1. No diff.
71# Need where __contains__ and == differ in side effects.
72# For list, __contains__ is equivalent to any(x == e for x in self). So same number of == calls.
73# Thus no side-effect difference for list.
74# For other containers? Rule also fires on sets, etc.
75# Set __contains__ uses hash, not == necessarily.
76# So: if item has custom __hash__ that has side effect.
77class Side3:
78 def __hash__(self):
79 print('HASH')
80 return 1
81 def __eq__(self, other):
82 return self is other
83s3 = Side3()
84print(s3 in {s3}) # Rule triggers: set lookup -> calls __hash__ once
85print('After in')
86# After fix: s3 == {s3}.pop() -> but fix would be s3 == next(iter({s3})) -> still calls __eq__.
87# And to get iterator, need to iterate set -> which may call __hash__ again.
88# Actually: next(iter({s3})) will call __hash__ on s3 to get hash, then look in set.
89# So: s3 in {s3}: calls __hash__ once
90# s3 == next(iter({s3})):
91# - next(iter({s3})): creates iterator, calls __hash__ to hash s3 for bucket, finds it, returns s3
92# - then s3 == s3: calls __eq__
93# So: one __hash__ vs one __hash__ + one __eq__
94# If __hash__ has side effect, count differs.
95hash_counter = 0
96eq_counter = 0
97class Side4:
98 def __hash__(self):
99 nonlocal hash_counter
100 hash_counter += 1
101 return 1
102 def __eq__(self, other):
103 nonlocal eq_counter
104 eq_counter += 1
105 return self is other
106s4 = Side4()
107hash_counter = eq_counter = 0
108print(s4 in {s4}) # should print HASH once
109hash_after_in = hash_counter
110eq_after_in = eq_counter
111print(f'HASH after in: {hash_after_in}, EQ after in: {eq_after_in}')
112hash_counter = eq_counter = 0
113print(s4 == next(iter({s4}))) # HASH (for finding in set) + EQ
114hash_after_eq = hash_counter
115eq_after_eq = eq_counter
116print(f'HASH after == : {hash_after_eq}, EQ after == : {eq_after_eq}')
117# Expected: after in: HASH=1, EQ=0; after ==: HASH=1, EQ=1
118# So EQ count differs.
r1-p004 · Byte string element vs container
1def f():
2 x = b'\x00'
3 # Rule triggers here: x in b'\x00'
4 return x in b'\x00'
5print(f()) # Should print True
6# After fix: would be x == b'\x00'[0] -> b'\x00' == 0 -> False
7# So output changes from True to False
True
r1-p005 · Mutable default argument aliasing
1def append_val(val, lst=[]):
2 lst.append(val)
3 return lst
4# Rule triggers: check if 0 in append_val(0)
5first = append_val(0)
6print(0 in first) # True: first is [0]
7second = append_val(0) # now [0,0]
8print(0 in second) # True
9# After fix: would be 0 == append_val(0)[0]
10# First call: append_val(0) returns [0]; [0][0] = 0; 0==0 -> True
11# Second call: append_val(0) returns [0,0]; [0,0][0] = 0; 0==0 -> True
12# Same. Need where container mutation makes equality fail but containment pass.
13# Use NaN in default.
14def add_nan(lst=[]):
15 lst.append(float('nan'))
16 return lst
17nan = float('nan')
18# Rule triggers: check if nan in add_nan()
19first = add_nan() # [nan]
20print(nan in first) # True
21print(nan == first[0]) # False
22# After fix, rule would rewrite nan in add_nan() to nan == add_nan()[0]
23# Which would print False instead of True
24# So to see difference, we need to print both versions.
25# But we can't print both in same run without calling add_nan twice.
26# First call for 'in' version:
27in_result = nan in add_nan() # add_nan called -> returns [nan]; nan in [nan] -> True
28# Second call for '==' version (simulating fixed code):
29equal_result = nan == add_nan()[0] # add_nan called again -> returns [nan]; nan == nan -> False
30print(f'in result: {in_result}')
31print(f'== result: {equal_result}')
32# Output: True then False
True True False False in result: False == result: False
r1-p006 · Ellipsis placeholder comparison
1def f():
2 # Rule triggers here: Ellipsis in [...]
3 return ... in [...]
4print(f()) # True
5# After fix: would be ... == [...] [0] -> ... == ... -> True
6# Still same. Need where ellipsis has special meaning.
7# Ellipsis is used in slicing: a[..., 0]
8# But comparing ellipsis to ellipsis is still True.
9# However, if someone overrides __eq__ for ellipsis? Can't.
10# Instead: use that Ellipsis is a singleton, but if the container is not a literal? Must be literal.
11# Make the ellipsis come from a variable that equals Ellipsis but is not the same object? Impossible.
12# Unless we define a class that instances equal to Ellipsis.
13class FakeEllipsis:
14 def __eq__(self, other):
15 return isinstance(other, type(Ellipsis))
16 def __repr__(self):
17 return 'FakeEllipsis()'
18fe = FakeEllipsis()
19# Rule does NOT trigger because container is [fe] but fe is not literal Ellipsis.
20# We need container literal with Ellipsis.
21# So: container is [...], literal.
22# Now, if we have code that expects Ellipsis to mean something else in context.
23# Example: using Ellipsis in slice notation.
24a = [[1,2,3], [4,5,6]]
25# Normally: a[:, 0] would be invalid, but a[..., 0] works
26slice_obj = (..., 0)
27# Rule triggers: check if slice_obj[0] in [...]
28print(slice_obj[0] in [...]) # True: Ellipsis in [...]
29# After fix: slice_obj[0] == [...] [0] -> Ellipsis == Ellipsis -> True
30# Same.
31# Instead: rely on the fact that ... == ... is True, but if we had a wrapper that lies about equality.
32# Can't change Ellipsis.
33# Give up and use: the hazard is that == ... is meaningless in most contexts, unlike containment which might be used in generic code.
34# But we need behavioral diff.
35# How about: if the container is not a list but a set, and Ellipsis is not hashable? But Ellipsis is hashable.
36# Another idea: in NumPy, ellipsis means something, but we can't use NumPy.
37# Finally: accept that for Ellipsis, containment and equality are both True, so no diff.
38# But the rule says "semantically opaque". Maybe just note that it's strange to compare to ...
39# We'll output the result and say it's True but confusing.
40print('Result of Ellipsis in [...]:', ... in [...])
41print('Result of Ellipsis == [...] [0]:', ... == [...] [0])
42# Both True
True True Result of Ellipsis in [...]: True Result of Ellipsis == [...] [0]: True
r1-p007 · Class attribute vs instance attribute with property
1class A:
2 x = 1
3 @property
4 def y(self):
5 print('GET_Y')
6 return 2
7a = A()
8# Rule triggers: check if a.y in [a.y]
9# First, evaluate a.y -> prints GET_Y, returns 2
10# Then, check 2 in [a.y] -> evaluates a.y again -> prints GET_Y again, returns 2
11# Then 2 in [2] -> True
12# So two GET_Y prints
13# After fix: a.y == [a.y][0]
14# Evaluate [a.y][0]: first a.y -> prints GET_Y, returns 2; builds [2]; [2][0] -> 2
15# Then a.y == 2 -> evaluates a.y -> prints GET_Y, returns 2; 2==2 -> True
16# So three GET_Y prints
17# Thus side effect count differs.
18get_y_count = 0
19class A2:
20 x = 1
21 @property
22 def y(self):
23 nonlocal get_y_count
24 get_y_count += 1
25 print(f'GET_Y #{get_y_count}')
26 return 2
27a2 = A2()
28get_y_count = 0
29print('Testing a2.y in [a2.y]:')
30res1 = a2.y in [a2.y]
31print(f'Result: {res1}')
32print(f'Total GET_Y calls: {get_y_count}')
33get_y_count = 0
34print('Testing a2.y == [a2.y][0]:')
35res2 = a2.y == [a2.y][0]
36print(f'Result: {res2}')
37print(f'Total GET_Y calls: {get_y_count}')
38# Expected: first test: 2 calls; second test: 3 calls
r1-p008 · Unpacking container literal
1y = 5
2# This is [*[y]] -> [5], but not a literal; rule should NOT trigger
3print(5 in [*[y]]) # True
4# Rule does not fire here because container is not a literal
5# If incorrectly fixed to 5 == [*[y]][0], that would be valid but rule shouldn't touch it
6# So we show it works as-is
7print('No rule trigger expected')
8# Also test near-miss: x in [y, *[]] -> [y] if y defined
9print(5 in [y, *[]]) # [5] -> True
10# Still not literal
11# x in [*()] -> empty
12print(5 in [*()]) # False
True No rule trigger expected True False
r2-p001 · Type annotation evaluated at runtime
1from typing import List
2
3def f() -> List[int]:
4 return []
5
6x = 1
7if x in [f()[0]] if f() else [0]: # Rule fires on [0]
8 print('in')
9else:
10 print('not in')
in
r2-p002 · String container matching empty string
1x = ''
2if x in "": # Rule does NOT fire: string len=0 → skip (but we want trigger)
3 print('matched')
4else:
5 print('not matched')
6if x == "":
7 print('eq matched')
8else:
9 print('eq not matched')
matched eq matched
r2-p003 · String formatting and repr/str edge cases
1x = 'None'
2s = f"{None!r}" # 'None'
3if x in s: # True: 'None' in 'None'
4 print('in formatted')
5else:
6 print('not in formatted')
7if x == s: # False: 'None' == 'None' → True? Wait — fix!
8 print('eq')
9else:
10 print('not eq')
in formatted eq
r2-p004 · Evaluation order and side effects
1def g():
2 print('side effect')
3 return 42
4x = g()
5if x in [g()]: # Rule fires: [g()] → calls g() again → prints twice
6 print('yes')
7else:
8 print('no')
side effect side effect yes
r2-p005 · NaN identity vs equality
1import math
2nan = float('nan')
3if nan in [nan]: # True (identity)
4 print('in: True')
5else:
6 print('in: False')
7if nan == nan: # False
8 print('eq: True')
9else:
10 print('eq: False')
in: True eq: False
r2-p006 · Mutable object __eq__ override
1class BadEq:
2 def __eq__(self, other):
3 print('__eq__ called')
4 return True
5obj = BadEq()
6x = obj
7if x in [obj]: # True by identity; __eq__ not called
8 print('in: yes')
9else:
10 print('in: no')
11if x == obj: # Calls __eq__ → prints
12 print('eq: yes')
13else:
14 print('eq: no')
in: yes __eq__ called eq: yes
r2-p007 · Not in with incomparable types
1class WeirdContainer:
2 def __contains__(self, item):
3 return str(item) == 'hello'
4xc = WeirdContainer()
5x = 5
6y = 'hello'
7if x not in xc: # '5' not in container → False? Wait: __contains__ returns True only if str(item)=='hello' → str(5)='5' → False → so 5 not in xc → True
8 print('not in: True')
9else:
10 print('not in: False')
11if x != y: # 5 != 'hello' → True in Python 3
12 print('neq: True')
13else:
14 print('neq: False')
not in: True neq: True
r2-p008 · Truthy/falsy and __bool__/__len__
1class EmptyButTrue:
2 def __len__(self):
3 return 0
4 def __bool__(self):
5 return True
6x = EmptyButTrue()
7if x in [x]: # Identity → True
8 print('in: True')
9else:
10 print('in: False')
11if x == x: # True (default __eq__ is identity)
12 print('eq: True')
13else:
14 print('eq: False')
in: True eq: True