chore: import upstream snapshot with attribution
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
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
This commit is contained in:
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user