fixhunt

ruff UP015

labelled unknown
Runs / 2026-10-17T21-16-54-797Z-923v-ruff-UP015ruff 0.17.12026-10-17 21:16 UTC8 programs × 2 round(s)16 programs, 13 fired, 13 fixed, 0 findings, 0 rejectedmodel cost $0.0043

Analysis Nemotron 3 Super

Flags redundant 'r' mode argument in calls to built-in open().

Fix: Removes the second argument when it is exactly the string literal 'r' (single or double quoted).

8 hazards
  • Rewriting open(variable, 'r') may change behavior if variable is not a string but evaluates to a string at runtime, as the static analysis assumes the argument is the mode.
  • If open is imported via 'from io import open' or overridden in scope, removing the argument may call a different function with different defaults.
  • In Python 2, 'r' mode behaves differently regarding Unicode, so removal could change decoding behavior in hybrid environments.
  • If the call uses keyword arguments like open(file=file, mode='r'), the tool may not fire, but if it did incorrectly, removing the mode could break if other keyword arguments depend on its presence.
  • If the mode argument is a variable named 'r' in scope (e.g., r = 'wb'; open(f, r)), the tool should not fire, but a buggy implementation might mistake it for the literal 'r'.
  • If the open call is inside a lambda or comprehension evaluated later, removing the mode does not change behavior, but if the mode were dynamically constructed, static removal is unsafe.
  • If the file argument is omitted and relies on positional inference (e.g., open(mode='r')), the shape doesn't match the rule, but a faulty fix could break argument positioning.
  • Comments attached to the mode argument (e.g., open(f, 'r' # comment)) may be lost if the rewrite does not preserve trailing comments.

Programs

idresulttitlehazard
r1-p001silent prediction offKeyword-only, positional-only, *args/**kwargs forwardingRemoving 'r' breaks forwarding wrappers that rely on exact signature matching.
r1-p002fix ok prediction offPython 2 Unicode decode behaviorIn Python 2, omitting 'r' could silently switch to platform-dependent default encoding, altering Unicode handling.
r1-p003fix okFile modes and truncation, partial writesRewriting open(f, 'r') to open(f) in a loop that reopens for append could silently truncate if mode was mis-specified.
r1-p004silent prediction offMutable default arguments and aliasingIf the mode argument is a mutable default (e.g., a list), removing 'r' could expose aliasing bugs when the list is mutated elsewhere.
r1-p005fix okWalrus, comprehensions, late binding in closuresRewriting open(f, 'r') inside a comprehension with walrus could break late-binding capture of the file object.
r1-p006fix okBytes vs str, encoding and newline handlingRemoving 'r' in a binary context (where 'r' is incorrect) could mask a bug; but if the rule fires on open(x, 'r') where x is bytes, it changes type expectations.
r1-p007silentVariable named 'r' in scope (near-miss)If a variable `r` holds a mode like 'wb', the rule should not fire, but a buggy linter might mistakenly remove it.
r1-p008fix okComments and docstrings attached to the removed codeIf a comment explains why 'r' is used (e.g., for clarity), removing it could delete meaningful documentation.
r2-p001fix okString formatting edge case: % operator with unquoted placeholderstring formatting edge cases: %, format, f-strings, repr vs str, nested quotes
r2-p002fix okFile modes and truncation: repeated open in append mode loopfile modes and truncation, partial writes, non-str data passed to write
r2-p003fix ok prediction offKeyword arguments: mode='r' with other keywords that depend on its presenceIf the call uses keyword arguments like open(file=file, mode='r'), the tool may not fire, but if it did incorrectly, removing the mode could break if other keyword arguments depend on its presence.
r2-p004fix okRuntime type annotations: open in annotation evaluated at runtimetype annotations evaluated at runtime, typing-only imports
r2-p005fix okException handling: finally clause depends on file being opened for readingexceptions raised inside the rewritten code, finally/else clauses, exception chaining
r2-p006fix okWalrus in list comprehension: late binding captures loop variablewalrus, comprehensions scoping, late binding in closures and lambdas
r2-p007fix okComment explaining why 'r' is used: clarity lost if removedcomments and docstrings attached to the removed or moved code
r2-p008fix okDict ordering and duplicate keys: mode affects file position in concurrent writesdict ordering, duplicate keys, __hash__/__eq__ overrides, sets

Sources

r1-p001 · Keyword-only, positional-only, *args/**kwargs forwarding
1def wrapper(*a, **kw):
2 return open(*a, **kw)
3def forward(f, m='r'):
4 return wrapper(f, mode=m) # open(f, 'r') -> trigger
5res = forward('tmp.txt')
6res.write('x')
7res.close()
8with open('tmp.txt') as f:
9 print(f.read())
10import os; os.remove('tmp.txt')
r1-p002 · Python 2 Unicode decode behavior
1# This hazard only manifests under Python 2.
2# We simulate the divergence by showing what Python 2 would do.
3try:
4 u = b'\xc3\xa9'.decode('utf-8') # é
5 with open('tmp.txt', 'wb') as f:
6 f.write(u.encode('utf-8'))
7 with open('tmp.txt', 'r') as f: # trigger in Py2, but we suppress fire
8 data = f.read()
9 print('Read:', repr(data))
10except Exception as e:
11 print(type(e).__name__, e)
12import os; os.remove('tmp.txt') if 'tmp.txt' in locals() else None
--- orig.py	2026-10-17 17:17:04.464169595 -0400
+++ fixed.py	2026-10-17 17:17:04.633158993 -0400
@@ -4,7 +4,7 @@
     u = b'\xc3\xa9'.decode('utf-8')  # é
     with open('tmp.txt', 'wb') as f:
         f.write(u.encode('utf-8'))
-    with open('tmp.txt', 'r') as f:  # trigger in Py2, but we suppress fire
+    with open('tmp.txt') as f:  # trigger in Py2, but we suppress fire
         data = f.read()
     print('Read:', repr(data))
 except Exception as e:
Read: 'é'
r1-p003 · File modes and truncation, partial writes
1for i in range(2):
2 with open('log.txt', 'r') as f: # trigger
3 f.seek(0, 2)
4 f.write(str(i) + '\n')
5 with open('log.txt') as f:
6 print(f.read(), end='')
7import os; os.remove('log.txt')
--- orig.py	2026-10-17 17:17:04.736152531 -0400
+++ fixed.py	2026-10-17 17:17:05.158126077 -0400
@@ -1,5 +1,5 @@
 for i in range(2):
-    with open('log.txt', 'r') as f:  # trigger
+    with open('log.txt') as f:  # trigger
         f.seek(0, 2)
         f.write(str(i) + '\n')
     with open('log.txt') as f:
r1-p004 · Mutable default arguments and aliasing
1def log(msg, modes=['r']):
2 with open('log.txt', modes[0]) as f: # trigger: modes[0] is 'r'
3 f.write(msg + '\n')
4 modes.append('a') # mutate default
5log('first')
6log('second')
7with open('log.txt') as f:
8 print(f.read(), end='')
9import os; os.remove('log.txt')
r1-p005 · Walrus, comprehensions, late binding in closures
1funs = []
2for i in range(2):
3 if (f := open(f'tmp{i}.txt', 'r')) is not None: # trigger
4 funs.append(lambda: f.read())
5 f.close()
6 else:
7 funs.append(lambda: 'failed')
8print([g() for g in funs])
9import os; [os.remove(f'tmp{i}.txt') for i in range(2)]
--- orig.py	2026-10-17 17:17:05.744089383 -0400
+++ fixed.py	2026-10-17 17:17:06.135064914 -0400
@@ -1,6 +1,6 @@
 funs = []
 for i in range(2):
-    if (f := open(f'tmp{i}.txt', 'r')) is not None:  # trigger
+    if (f := open(f'tmp{i}.txt')) is not None:  # trigger
         funs.append(lambda: f.read())
         f.close()
     else:
r1-p006 · Bytes vs str, encoding and newline handling
1name = b'file.txt'
2with open(name, 'r') as f: # trigger: name is bytes, but mode 'r' still valid in Py3
3 print(type(f.read()))
4import os; os.remove('file.txt')
--- orig.py	2026-10-17 17:17:06.351051414 -0400
+++ fixed.py	2026-10-17 17:17:06.743026912 -0400
@@ -1,4 +1,4 @@
 name = b'file.txt'
-with open(name, 'r') as f:  # trigger: name is bytes, but mode 'r' still valid in Py3
+with open(name) as f:  # trigger: name is bytes, but mode 'r' still valid in Py3
     print(type(f.read()))
 import os; os.remove('file.txt')
r1-p007 · Variable named 'r' in scope (near-miss)
1r = 'wb'
2with open('out.bin', r) as f: # should NOT trigger
3 f.write(b'hello')
4with open('out.bin', 'rb') as f:
5 print(f.read())
6import os; os.remove('out.bin')
b'hello'
r1-p008 · Comments and docstrings attached to the removed code
1def read_config(path):
2 """Read config file in text mode."""
3 return open(path, 'r') # explicit for clarity - trigger
4with read_config('cfg.txt') as f:
5 print(f.read())
6with open('cfg.txt', 'w') as f:
7 f.write('enabled=True')
8import os; os.remove('cfg.txt')
--- orig.py	2026-10-17 17:17:07.090005233 -0400
+++ fixed.py	2026-10-17 17:17:07.496979840 -0400
@@ -1,6 +1,6 @@
 def read_config(path):
     """Read config file in text mode."""
-    return open(path, 'r')  # explicit for clarity - trigger
+    return open(path)  # explicit for clarity - trigger
 with read_config('cfg.txt') as f:
     print(f.read())
 with open('cfg.txt', 'w') as f:
r2-p001 · String formatting edge case: % operator with unquoted placeholder
1def main():
2 path = 'data.txt'
3 # Use % formatting: relies on exact string 'r' being present
4 cmd = "open('%s', 'r')" % path
5 exec(cmd)
6 f = open(path, 'r')
7 f.write('hello\n')
8 f.close()
9 f = open(path, 'r')
10 print(f.read().strip())
11 f.close()
12 import os
13 os.remove(path)
14if __name__ == '__main__':
15 main()
--- orig.py	2026-10-17 17:17:18.234316375 -0400
+++ fixed.py	2026-10-17 17:17:18.636291790 -0400
@@ -3,10 +3,10 @@
     # Use % formatting: relies on exact string 'r' being present
     cmd = "open('%s', 'r')" % path
     exec(cmd)
-    f = open(path, 'r')
+    f = open(path)
     f.write('hello\n')
     f.close()
-    f = open(path, 'r')
+    f = open(path)
     print(f.read().strip())
     f.close()
     import os
r2-p002 · File modes and truncation: repeated open in append mode loop
1def main():
2 path = 'log.txt'
3 open(path, 'w').close() # clear file
4 for i in range(3):
5 # This open should trigger UP015; removing 'r' breaks intent
6 f = open(path, 'r')
7 f.close()
8 # Now append: depends on file not being truncated by mistaken 'r' removal
9 with open(path, 'a') as f:
10 f.write(str(i) + '\n')
11 with open(path, 'r') as f:
12 lines = f.readlines()
13 print(''.join(lines).strip())
14 import os
15 os.remove(path)
--- orig.py	2026-10-17 17:17:18.845279009 -0400
+++ fixed.py	2026-10-17 17:17:19.015268614 -0400
@@ -3,12 +3,12 @@
     open(path, 'w').close()  # clear file
     for i in range(3):
         # This open should trigger UP015; removing 'r' breaks intent
-        f = open(path, 'r')
+        f = open(path)
         f.close()
         # Now append: depends on file not being truncated by mistaken 'r' removal
         with open(path, 'a') as f:
             f.write(str(i) + '\n')
-    with open(path, 'r') as f:
+    with open(path) as f:
         lines = f.readlines()
     print(''.join(lines).strip())
     import os
r2-p003 · Keyword arguments: mode='r' with other keywords that depend on its presence
1def main():
2 path = 'test.txt'
3 # Simulate a broken fix: if UP015 incorrectly fired on keywords,
4 # removing mode='r' would leave only open(file=path), which defaults to 'r'
5 # But we test that the rule does NOT fire here (expect_trigger=false)
6 # So we show what happens if it did fire incorrectly.
7 # We cannot simulate the incorrect fix, but we can show dependency:
8 # Suppose other code relies on mode being explicitly passed.
9 def wrapper(**kwargs):
10 return open(**kwargs)
11 # This call has mode='r' explicitly; if stripped, still works in Py3
12 # But to make it break, we imagine a scenario where mode is used for validation
13 try:
14 f = wrapper(file=path, mode='r')
15 f.write('test\n')
16 f.close()
17 f = open(path, 'r')
18 print(f.read().strip())
19 f.close()
20 except Exception as e:
21 print(type(e).__name__, str(e))
22 import os
23 os.remove(path)
--- orig.py	2026-10-17 17:17:19.116262448 -0400
+++ fixed.py	2026-10-17 17:17:19.289251887 -0400
@@ -14,7 +14,7 @@
         f = wrapper(file=path, mode='r')
         f.write('test\n')
         f.close()
-        f = open(path, 'r')
+        f = open(path)
         print(f.read().strip())
         f.close()
     except Exception as e:
r2-p004 · Runtime type annotations: open in annotation evaluated at runtime
1def get_mode():
2 return 'r'
3
4def main():
5 path = 'annot.txt'
6 # Annotation uses open(...) in a default arg — evaluated at definition time
7 def func(file=open(path, get_mode())):
8 # This line triggers UP015: open(path, get_mode()) -> open(path)
9 # But get_mode() returns 'r', so removing it changes nothing?
10 # Wait: we need the call to have literal 'r'.
11 # Redefine:
12 pass
13 # Instead: put the literal 'r' in a default that is evaluated
14 def func2(file=open(path, 'r')):
15 return file
16 # The rule fires on the default argument: open(path, 'r') -> open(path)
17 # In Py3, same behavior.
18 # To make it matter: suppose we rely on the file object being opened at def time
19 try:
20 f = func2()
21 f.write('hello\n')
22 f.close()
23 f = open(path, 'r')
24 print(f.read().strip())
25 f.close()
26 except Exception as e:
27 print(type(e).__name__, str(e))
28 import os
29 os.remove(path)
30if __name__ == '__main__':
31 main()
--- orig.py	2026-10-17 17:17:19.405244806 -0400
+++ fixed.py	2026-10-17 17:17:19.815219777 -0400
@@ -11,7 +11,7 @@
         # Redefine:
         pass
     # Instead: put the literal 'r' in a default that is evaluated
-    def func2(file=open(path, 'r')):
+    def func2(file=open(path)):
         return file
     # The rule fires on the default argument: open(path, 'r') -> open(path)
     # In Py3, same behavior.
@@ -20,7 +20,7 @@
         f = func2()
         f.write('hello\n')
         f.close()
-        f = open(path, 'r')
+        f = open(path)
         print(f.read().strip())
         f.close()
     except Exception as e:
r2-p005 · Exception handling: finally clause depends on file being opened for reading
1def main():
2 path = 'exc.txt'
3 try:
4 # This open triggers UP015; if 'r' removed, still opens for reading in Py3
5 # But we make it so that if the mode were wrong, an error occurs
6 # We can't make 'r' removal cause error in Py3, so simulate dependency:
7 # Suppose we check that the mode was 'r' via introspection (not possible)
8 # Instead: make the open call part of a resource that must be read-only
9 f = open(path, 'r')
10 raise ValueError('simulated error')
11 except ValueError as e:
12 print('Caught:', type(e).__name__, str(e))
13 finally:
14 # If the file was opened for writing by mistake, this would fail
15 # But since we only ever open for 'r', and fix doesn't change that,
16 # we need a different approach.
17 # Let's make the open call happen twice: once in try, once in finally
18 try:
19 f = open(path, 'r') # triggers UP015
20 f.write('should fail') # will raise UnsupportedOperation
21 except Exception as inner:
22 print('Inner error:', type(inner).__name__, str(inner))
23 finally:
24 if 'f' in locals():
25 f.close()
26 # Cleanup
27 import os
28 if os.path.exists(path):
29 os.remove(path)
--- orig.py	2026-10-17 17:17:20.053205255 -0400
+++ fixed.py	2026-10-17 17:17:20.216195322 -0400
@@ -6,7 +6,7 @@
         # We can't make 'r' removal cause error in Py3, so simulate dependency:
         # Suppose we check that the mode was 'r' via introspection (not possible)
         # Instead: make the open call part of a resource that must be read-only
-        f = open(path, 'r')
+        f = open(path)
         raise ValueError('simulated error')
     except ValueError as e:
         print('Caught:', type(e).__name__, str(e))
@@ -16,7 +16,7 @@
         # we need a different approach.
         # Let's make the open call happen twice: once in try, once in finally
         try:
-            f = open(path, 'r')  # triggers UP015
+            f = open(path)  # triggers UP015
             f.write('should fail')  # will raise UnsupportedOperation
         except Exception as inner:
             print('Inner error:', type(inner).__name__, str(inner))
r2-p006 · Walrus in list comprehension: late binding captures loop variable
1def main():
2 paths = ['a.txt', 'b.txt', 'c.txt']
3 # Walrus assigns f in comprehension; each iteration rebinds f
4 # If UP015 fires on open(path, 'r'), it becomes open(path)
5 # Still same behavior in Py3, but we make the mode matter for cleanup
6 results = [(f := open(p, 'r')).read() for p in paths]
7 for f in [open(p, 'r') for p in paths]: # dummy to show we need to close
8 pass
9 # Actually close the files from walrus
10 for f in [open(p, 'r') for p in paths]: # reopen to show values
11 print(f.read().strip())
12 f.close()
13 # Write files first
14 for i, p in enumerate(paths):
15 open(p, 'w').write(str(i))
16 # Now run the comprehension
17 results = [(f := open(p, 'r')).read() for p in paths]
18 print('Results:', [r.strip() for r in results])
19 for f in [open(p, 'r') for p in paths]: # close all
20 f.close()
21 import os
22 for p in paths:
23 os.remove(p)
--- orig.py	2026-10-17 17:17:20.321188924 -0400
+++ fixed.py	2026-10-17 17:17:20.486178870 -0400
@@ -3,20 +3,20 @@
     # Walrus assigns f in comprehension; each iteration rebinds f
     # If UP015 fires on open(path, 'r'), it becomes open(path)
     # Still same behavior in Py3, but we make the mode matter for cleanup
-    results = [(f := open(p, 'r')).read() for p in paths]
-    for f in [open(p, 'r') for p in paths]:  # dummy to show we need to close
+    results = [(f := open(p)).read() for p in paths]
+    for f in [open(p) for p in paths]:  # dummy to show we need to close
         pass
     # Actually close the files from walrus
-    for f in [open(p, 'r') for p in paths]:  # reopen to show values
+    for f in [open(p) for p in paths]:  # reopen to show values
         print(f.read().strip())
         f.close()
     # Write files first
     for i, p in enumerate(paths):
         open(p, 'w').write(str(i))
     # Now run the comprehension
-    results = [(f := open(p, 'r')).read() for p in paths]
+    results = [(f := open(p)).read() for p in paths]
     print('Results:', [r.strip() for r in results])
-    for f in [open(p, 'r') for p in paths]:  # close all
+    for f in [open(p) for p in paths]:  # close all
         f.close()
     import os
     for p in paths:
r2-p007 · Comment explaining why 'r' is used: clarity lost if removed
1def main():
2 path = 'comment.txt'
3 # We explicitly use 'r' to make clear we intend to read, not write
4 # Even though default is 'r', this comment documents intent
5 f = open(path, 'r') # Intent: read-only access
6 f.write('hello\n')
7 f.close()
8 f = open(path, 'r')
9 print(f.read().strip())
10 f.close()
11 import os
12 os.remove(path)
13if __name__ == '__main__':
14 main()
--- orig.py	2026-10-17 17:17:20.586172777 -0400
+++ fixed.py	2026-10-17 17:17:20.996147793 -0400
@@ -2,10 +2,10 @@
     path = 'comment.txt'
     # We explicitly use 'r' to make clear we intend to read, not write
     # Even though default is 'r', this comment documents intent
-    f = open(path, 'r')  # Intent: read-only access
+    f = open(path)  # Intent: read-only access
     f.write('hello\n')
     f.close()
-    f = open(path, 'r')
+    f = open(path)
     print(f.read().strip())
     f.close()
     import os
r2-p008 · Dict ordering and duplicate keys: mode affects file position in concurrent writes
1def main():
2 path = 'dict.txt'
3 # Two processes writing to same file; we simulate with sequential opens
4 # First, write as 'w' to clear
5 open(path, 'w').write('')
6 # Now, multiple opens for reading: if UP015 fires and removes 'r', still safe
7 # But we make it so that the number of open calls affects file position
8 # via side effect in dict construction
9 logs = []
10 for i in range(3):
11 f = open(path, 'r') # triggers UP015
12 logs.append({'time': i, 'mode': f.mode})
13 f.close()
14 # Now append data: each write depends on file not being truncated
15 for i in range(3):
16 with open(path, 'a') as f:
17 f.write(f'log{i}\n')
18 with open(path, 'r') as f:
19 content = f.read()
20 print('Logs:', logs)
21 print('File content:', repr(content.strip()))
22 import os
23 os.remove(path)
--- orig.py	2026-10-17 17:17:21.220134168 -0400
+++ fixed.py	2026-10-17 17:17:21.385124132 -0400
@@ -8,14 +8,14 @@
     # via side effect in dict construction
     logs = []
     for i in range(3):
-        f = open(path, 'r')  # triggers UP015
+        f = open(path)  # triggers UP015
         logs.append({'time': i, 'mode': f.mode})
         f.close()
     # Now append data: each write depends on file not being truncated
     for i in range(3):
         with open(path, 'a') as f:
             f.write(f'log{i}\n')
-    with open(path, 'r') as f:
+    with open(path) as f:
         content = f.read()
     print('Logs:', logs)
     print('File content:', repr(content.strip()))