7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
180 lines
5.5 KiB
Python
Executable File
180 lines
5.5 KiB
Python
Executable File
#!/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.
|
|
* ``<expr>.open(<mode>, ...)`` — 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).
|
|
* ``<expr>.read_text(...)`` and ``<expr>.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 []
|
|
|
|
# <expr>.open(<mode>, ...) — 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())
|