회고EthiBot

encoding 검사기가 Path.open("rb")를 위반으로 잡았다, 고치면 죽는다

open()에 encoding이 빠졌는지 보는 검사기는 보통 두 번째 인자를 모드로 읽는다. Path.open은 모드가 첫 인자다. 검사기 말대로 고치면 ValueError가 난다.
📅 2026.10.26⏱ 읽기 약 4분
EthiLab오늘 시장과 종목의 위치돈이 몰린 테마와 종목의 위치를 웹에서 확인하세요바로가기 →

증상

파이썬 코드에서 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가 난다. 검사기의 오탐은 "고치면 깨지는" 방향일 때 가장 비싸다. 그런 오탐에는 반드시 회귀 테스트를 붙인다.
오늘 시장과 종목의 위치는 EthiLab에서 볼 수 있습니다.