증상
파이썬 코드에서 open()에 encoding=을 빠뜨린 곳을 찾는 검사기를 AST로 만들었다. Windows에서는 기본 인코딩이 UTF-8이 아니라서 빠뜨리면 한글이 깨지기 때문이다. 바이너리 모드("rb", "wb")는 encoding을 받지 않으니 검사에서 뺀다.
그런데 이 코드가 위반으로 잡혔다.
p = Path("all_tickers.pkl")
with p.open("rb") as fh:
data = pickle.load(fh)
바이너리로 여는데 encoding을 넣으라고 한다. 검사기 말대로 고치면 이렇게 된다.
with p.open("rb", encoding="utf-8") as fh: # ValueError
ValueError: binary mode doesn't take an encoding argument
검사기를 믿고 고치는 순간 멀쩡한 코드가 깨진다.
원인: 모드가 놓이는 자리가 호출 형태마다 다르다
모드를 찾을 때 "두 번째 위치 인자"를 봤다. 빌트인은 그게 맞다.
| 호출 | 모드 위치 |
|---|---|
open(path, "rb") | 두 번째 |
io.open(path, "rb") | 두 번째 |
Path(...).open("rb") | 첫 번째 |
Path.open은 경로가 이미 객체에 들어 있으니 첫 인자가 모드다. 두 번째 자리를 보면 아무것도 없고, 모드 없음은 기본값 "r"(텍스트)로 판정돼 위반이 된다.
해결
호출 형태로 모드 자리를 먼저 정하고, 그 자리의 리터럴만 모드로 해석한다.
import ast
_MODE_CHARS = frozenset("rwxab+t")
def _is_mode_literal(value: object) -> bool:
# 모드 문자로만 이루어진 문자열만 모드로 본다 - "backup.bin" 같은 경로 오인 방지
return isinstance(value, str) and bool(value) and set(value) <= _MODE_CHARS
def _mode_arg_index(call: ast.Call) -> int:
func = call.func
if isinstance(func, ast.Name): # open(path, mode)
return 1
if (isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Name)
and func.value.id == "io"): # io.open(path, mode)
return 1
return 0 # Path(...).open(mode) 등 메서드 호출
def has_binary_mode(call: ast.Call) -> bool:
for kw in call.keywords:
if kw.arg == "mode" and isinstance(kw.value, ast.Constant):
return "b" in str(kw.value.value)
i = _mode_arg_index(call)
if len(call.args) > i:
arg = call.args[i]
return (isinstance(arg, ast.Constant)
and _is_mode_literal(arg.value)
and "b" in arg.value)
return False
_is_mode_literal이 하는 일이 하나 더 있다. 자리만 보고 "b"가 들어 있는지 검사하면, 메서드 호출에서 첫 인자가 경로인 경우(something.open("backup.bin"))도 바이너리로 오인한다. 문자열이 rwxab+t로만 이루어졌을 때만 모드로 본다.
회귀 테스트로 잠근다
세 가지를 고정했다.
def test_builtin_binary_not_flagged():
assert check('with open("f.bin", "rb") as fh: pass') == []
def test_path_open_binary_not_flagged():
src = 'p = Path("all_tickers.pkl")\nwith p.open("rb") as fh: pass'
assert check(src) == []
def test_path_open_text_still_flagged():
assert len(check('with Path("f.txt").open() as fh: pass')) == 1
세 번째가 중요하다. 바이너리 오판을 고치다가 진짜 위반(텍스트로 열면서 encoding 없음)까지 놓치면 검사기가 할 일을 안 하게 된다.
정리
- AST로
open()계열을 검사할 때 모드 위치는 호출 형태로 정한다. 이름 호출·io.open은 두 번째, 메서드 호출은 첫 번째. - 그 자리의 리터럴이 모드 문자로만 이루어졌는지 확인한다. 경로 문자열에 "b"가 들어 있는 경우를 막는다.
- 바이너리 모드에 encoding을 넣으면
ValueError가 난다. 검사기의 오탐은 "고치면 깨지는" 방향일 때 가장 비싸다. 그런 오탐에는 반드시 회귀 테스트를 붙인다.