#!/usr/bin/env python3 """ Pre-commit hook to enforce explicit ``encoding=`` on text-mode file I/O calls. On Windows the default encoding is the system locale (often cp1252), not UTF-8. Omitting ``encoding`` causes silent failures or ``UnicodeDecodeError`` when reading/writing UTF-8 files. See issue #3743. Detects: * Bare ``open(...)`` — second positional arg is the mode. * ``.open(, ...)`` — only when the first positional arg is a constant string that looks like a real file mode (avoids false positives on ``tarfile.open`` / ``zipfile.open`` etc., which take a path first). * ``.read_text(...)`` and ``.write_text(...)`` — these are effectively pathlib-only, so any bare call without ``encoding=`` is flagged. """ import ast import sys from pathlib import Path _FILE_MODE_CHARS = frozenset("rwxabt+") def _looks_like_file_mode(value: object) -> bool: """True if value is a string that plausibly is a file ``open`` mode.""" return ( isinstance(value, str) and 0 < len(value) <= 3 and set(value) <= _FILE_MODE_CHARS ) def _has_encoding_keyword(call: ast.Call) -> bool: """True if the call has an explicit ``encoding=`` kwarg. Treats ``**kwargs`` spreads as "may contain encoding" to avoid false positives — we can't statically prove the spread doesn't supply it. """ for kw in call.keywords: if kw.arg is None: # **kwargs spread return True if kw.arg == "encoding": return True return False def _get_mode_arg(call: ast.Call, positional_index: int) -> ast.expr | None: """Return the mode AST node, looking at both positional and ``mode=`` kwarg. Returns None if no mode was supplied (caller defaults to text mode). """ if len(call.args) > positional_index: return call.args[positional_index] for kw in call.keywords: if kw.arg == "mode": return kw.value return None def _is_text_mode_at(call: ast.Call, mode_arg_index: int) -> bool | None: """Return True if the mode argument is text mode. Inspects both the positional slot and the ``mode=`` kwarg, so calls like ``open(f, mode="rb")`` are correctly classified as binary even though the positional slot is empty. Returns ``None`` (falsy) when the mode can't be determined statically — e.g. ``open(filepath, mode)`` where ``mode`` is a variable. """ mode_arg = _get_mode_arg(call, mode_arg_index) if mode_arg is None: return True # default mode is "r" (text) if not isinstance(mode_arg, ast.Constant): return None return "b" not in str(mode_arg.value) def _violations_for_call(node: ast.Call) -> list[tuple[int, str]]: func = node.func # Bare open(...) — second positional arg is mode. if isinstance(func, ast.Name) and func.id == "open": if _is_text_mode_at(node, 1) and not _has_encoding_keyword(node): return [(node.lineno, "open() called without explicit encoding=")] return [] if not isinstance(func, ast.Attribute): return [] attr = func.attr # Path.read_text() / Path.write_text() — encoding= is the only safe option. if attr in {"read_text", "write_text"}: if not _has_encoding_keyword(node): return [ ( node.lineno, f".{attr}() called without explicit encoding=", ) ] return [] # .open(, ...) — first positional arg is the mode (or mode= kwarg). # When the mode looks like a file mode (or is omitted entirely, defaulting # to "r"), flag missing encoding. The mode-shape filter avoids false # positives on tarfile.open("foo.tar") / zipfile.ZipFile.open("inner") etc. # — bare ``.open()`` is the same gap that bare ``open(f)`` already catches. if attr == "open": mode_arg = _get_mode_arg(node, 0) flag = False if mode_arg is None: flag = True # defaults to "r" — same risk as bare open(f) elif isinstance(mode_arg, ast.Constant) and _looks_like_file_mode( mode_arg.value ): flag = "b" not in mode_arg.value if flag and not _has_encoding_keyword(node): return [ ( node.lineno, ".open() called without explicit encoding=", ) ] return [] def check_file(file_path: str) -> list[tuple[int, str]]: path = Path(file_path) if path.suffix != ".py": return [] try: source = path.read_text(encoding="utf-8") except Exception: return [] try: tree = ast.parse(source, filename=file_path) except SyntaxError: return [] violations: list[tuple[int, str]] = [] for node in ast.walk(tree): if isinstance(node, ast.Call): violations.extend(_violations_for_call(node)) return violations def main() -> int: exit_code = 0 for file_path in sys.argv[1:]: violations = check_file(file_path) for lineno, message in violations: print(f"{file_path}:{lineno}: {message}") exit_code = 1 if exit_code: print() print( "Hint: add encoding='utf-8' (or 'utf-8-sig' for JSON config files)" ) print( " to all text-mode open() / Path.open() / read_text() / write_text() calls." ) print(" See issue #3743.") return exit_code if __name__ == "__main__": sys.exit(main())