ruff UP015
labelled unknown
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
| id | result | title | hazard |
|---|---|---|---|
| r1-p001 | silent prediction off | Keyword-only, positional-only, *args/**kwargs forwarding | Removing 'r' breaks forwarding wrappers that rely on exact signature matching. |
| r1-p002 | fix ok prediction off | Python 2 Unicode decode behavior | In Python 2, omitting 'r' could silently switch to platform-dependent default encoding, altering Unicode handling. |
| r1-p003 | fix ok | File modes and truncation, partial writes | Rewriting open(f, 'r') to open(f) in a loop that reopens for append could silently truncate if mode was mis-specified. |
| r1-p004 | silent prediction off | Mutable default arguments and aliasing | If the mode argument is a mutable default (e.g., a list), removing 'r' could expose aliasing bugs when the list is mutated elsewhere. |
| r1-p005 | fix ok | Walrus, comprehensions, late binding in closures | Rewriting open(f, 'r') inside a comprehension with walrus could break late-binding capture of the file object. |
| r1-p006 | fix ok | Bytes vs str, encoding and newline handling | Removing '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-p007 | silent | Variable 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-p008 | fix ok | Comments and docstrings attached to the removed code | If a comment explains why 'r' is used (e.g., for clarity), removing it could delete meaningful documentation. |
| r2-p001 | fix ok | String formatting edge case: % operator with unquoted placeholder | string formatting edge cases: %, format, f-strings, repr vs str, nested quotes |
| r2-p002 | fix ok | File modes and truncation: repeated open in append mode loop | file modes and truncation, partial writes, non-str data passed to write |
| r2-p003 | fix ok prediction off | Keyword arguments: mode='r' with other keywords that depend on its presence | 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. |
| r2-p004 | fix ok | Runtime type annotations: open in annotation evaluated at runtime | type annotations evaluated at runtime, typing-only imports |
| r2-p005 | fix ok | Exception handling: finally clause depends on file being opened for reading | exceptions raised inside the rewritten code, finally/else clauses, exception chaining |
| r2-p006 | fix ok | Walrus in list comprehension: late binding captures loop variable | walrus, comprehensions scoping, late binding in closures and lambdas |
| r2-p007 | fix ok | Comment explaining why 'r' is used: clarity lost if removed | comments and docstrings attached to the removed or moved code |
| r2-p008 | fix ok | Dict ordering and duplicate keys: mode affects file position in concurrent writes | dict 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()))