Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

318 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Auto-fixer for exception variable leaks and exc_info in production logs.
Handles two patterns:
1. Exception variables in log messages (f"...{e}", "...%s", e)
→ Removes the exception variable from the message
2. exc_info=True on warning/error/critical logs
→ Removes the exc_info keyword argument
Note: this hook is part of the reason we do NOT enforce ``raise X from e``
universally (see ADR-0003). Preserving exception chains via ``from e``
would re-expose the details this hook strips from logs.
Run standalone:
python .pre-commit-hooks/fix-exception-logging.py src/
Or via the pre-commit hook:
python .pre-commit-hooks/check-sensitive-logging.py --fix <files>
"""
import ast
import re
import sys
from pathlib import Path
from typing import List, Optional, Set, Tuple
# ---------------------------------------------------------------------------
# AST-based violation finder
# ---------------------------------------------------------------------------
class _ViolationFinder(ast.NodeVisitor):
"""Find exception-var and exc_info violations with precise locations."""
def __init__(self) -> None:
self.exc_var_violations: List[Tuple[int, int, str, Set[str]]] = []
# (start_line, end_line, level, except_var_names)
self.exc_info_violations: List[Tuple[int, int]] = []
# (keyword_line, keyword_end_line)
self._except_var_stack: List[Optional[str]] = []
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
self._except_var_stack.append(node.name)
self.generic_visit(node)
self._except_var_stack.pop()
def visit_Call(self, node: ast.Call) -> None:
if self._is_logger_call(node):
level = self._get_level(node)
if level and level not in {"exception", "debug"}:
self._check_exc_var(node, level)
if level in {"warning", "error", "critical"}:
self._check_exc_info(node)
self.generic_visit(node)
@staticmethod
def _is_logger_call(node: ast.Call) -> bool:
if isinstance(node.func, ast.Attribute):
if node.func.attr in {
"debug",
"info",
"warning",
"error",
"critical",
"exception",
}:
if isinstance(node.func.value, ast.Name):
return node.func.value.id == "logger"
if isinstance(node.func.value, ast.Attribute):
return node.func.value.attr == "logger"
return False
@staticmethod
def _get_level(node: ast.Call) -> Optional[str]:
if isinstance(node.func, ast.Attribute):
return node.func.attr
return None
def _check_exc_var(self, node: ast.Call, level: str) -> None:
except_vars = {v for v in self._except_var_stack if v is not None}
if not except_vars:
return
for arg in node.args:
if self._references(arg, except_vars):
self.exc_var_violations.append(
(
node.lineno,
node.end_lineno or node.lineno,
level,
except_vars,
)
)
return
def _check_exc_info(self, node: ast.Call) -> None:
for kw in node.keywords:
if (
kw.arg == "exc_info"
and isinstance(kw.value, ast.Constant)
and kw.value.value is True
):
self.exc_info_violations.append(
(kw.lineno, kw.end_lineno or kw.lineno)
)
def _references(self, expr: ast.AST, names: Set[str]) -> bool:
if isinstance(expr, ast.Name):
return expr.id in names
if isinstance(expr, ast.JoinedStr):
return any(
isinstance(v, ast.FormattedValue)
and self._references(v.value, names)
for v in expr.values
)
if isinstance(expr, ast.Call):
return any(self._references(a, names) for a in expr.args)
if isinstance(expr, ast.BinOp):
return self._references(expr.left, names) or self._references(
expr.right, names
)
if isinstance(expr, ast.Tuple):
return any(self._references(el, names) for el in expr.elts)
return False
# ---------------------------------------------------------------------------
# Text-based fixers
# ---------------------------------------------------------------------------
# Patterns for exception variable references in f-strings
# Matches: {e}, {e!s}, {e!r}, {exc}, {err}, {error}, {ex}
_FSTR_EXC_RE = re.compile(
r"""
[,;:\s\-–—]* # leading separator/whitespace before {e}
\{ # opening brace
(?:e|ex|exc|err|error) # common exception variable names
(?:![sra])? # optional conversion (!s, !r, !a)
(?::[^}]*)? # optional format spec
\} # closing brace
[,;:\s\-–—.]* # trailing separator/punctuation after {e}
""",
re.VERBOSE,
)
# Matches trailing , e) or , exc) etc. as positional arg in logger call
_PCTARG_EXC_RE = re.compile(r",\s*(?:e|ex|exc|err|error)\s*(?=\))")
# Matches %s (or %r, %d) placeholders that correspond to the exception arg
_PCT_PLACEHOLDER_RE = re.compile(r"[,;:\s\-–—]*%[srd][,;:\s\-–—.]*")
def _fix_exc_var_in_line(line: str, except_vars: Set[str]) -> str:
"""Remove exception variable references from a single line."""
original = line
# Build dynamic regex for the specific variable names in this scope
var_names = "|".join(re.escape(v) for v in except_vars)
# Fix f-string interpolation: {e}, {exc}, etc.
fstr_re = re.compile(
r"[,;:\s\-–—]*"
r"\{"
rf"(?:{var_names})"
r"(?:![sra])?"
r"(?::[^}]*)?"
r"\}"
r"[,;:\s\-–—.]*",
)
line = fstr_re.sub("", line)
# If we removed the only content of an f-string, convert to regular string
# f"" → ""
line = re.sub(r'\bf("")', r"\1", line)
line = re.sub(r"\bf('')", r"\1", line)
# Fix %-style positional arg: , e) → )
pctarg_re = re.compile(rf",\s*(?:{var_names})\s*(?=\))")
line = pctarg_re.sub("", line)
# If we removed a positional arg, also clean up the %s in the format string
if line != original and "%s" in line:
# Only remove trailing %s (the one that matched the exception var)
# This is conservative — only handles the simple single-%s case
line = re.sub(r"[,;:\s\-–—]*%s[,;:\s\-–—.]*(?=\")", "", line, count=1)
return line
def _fix_exc_info_in_line(line: str) -> str:
"""Remove exc_info=True from a line."""
# Handle: , exc_info=True) or , exc_info=True, or (exc_info=True)
line = re.sub(r",\s*exc_info=True", "", line)
line = re.sub(r"exc_info=True,\s*", "", line)
line = re.sub(r"exc_info=True", "", line)
return line
def _remove_unused_as_clause(lines: List[str], except_vars: Set[str]) -> None:
"""Remove 'as e' from except clauses when e is no longer used."""
for i, line in enumerate(lines):
for var in except_vars:
# Match: except SomeError as e: or except Exception as e:
pattern = re.compile(
rf"(\s*except\s+\S+(?:\s*,\s*\S+)*)\s+as\s+{re.escape(var)}\s*:"
)
m = pattern.match(line)
if m:
# Check if the variable is still used anywhere in nearby lines
# (simple heuristic: check next 20 lines in the except block)
still_used = False
for j in range(i + 1, min(i + 20, len(lines))):
if re.search(rf"\b{re.escape(var)}\b", lines[j]):
still_used = True
break
# Stop at next except/def/class/return at same or lesser indent
if re.match(r"\S", lines[j]):
break
if not still_used:
lines[i] = pattern.sub(r"\1:", line)
# ---------------------------------------------------------------------------
# File-level fix
# ---------------------------------------------------------------------------
def fix_file(filepath: str) -> Tuple[bool, int]:
"""Fix a file in-place. Returns (changed, fix_count)."""
path = Path(filepath)
try:
source = path.read_text(encoding="utf-8")
except Exception:
return False, 0
try:
tree = ast.parse(source, filename=filepath)
except SyntaxError:
return False, 0
finder = _ViolationFinder()
finder.visit(tree)
if not finder.exc_var_violations and not finder.exc_info_violations:
return False, 0
lines = source.splitlines(keepends=True)
fix_count = 0
# Collect all except variable names used across violations in this file
all_except_vars: Set[str] = set()
# Fix exception variable violations
for start_line, end_line, _level, except_vars in finder.exc_var_violations:
all_except_vars |= except_vars
for i in range(start_line - 1, min(end_line, len(lines))):
fixed = _fix_exc_var_in_line(lines[i], except_vars)
if fixed != lines[i]:
lines[i] = fixed
fix_count += 1
# Fix exc_info violations
for start_line, end_line in finder.exc_info_violations:
for i in range(start_line - 1, min(end_line, len(lines))):
fixed = _fix_exc_info_in_line(lines[i])
if fixed != lines[i]:
lines[i] = fixed
fix_count += 1
# Clean up unused 'as e' clauses
if all_except_vars:
_remove_unused_as_clause(lines, all_except_vars)
if fix_count > 0:
path.write_text("".join(lines), encoding="utf-8")
return True, fix_count
return False, 0
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
paths: List[str] = []
for arg in sys.argv[1:]:
p = Path(arg)
if p.is_dir():
paths.extend(str(f) for f in p.rglob("*.py"))
elif p.suffix == ".py":
paths.append(str(p))
if not paths:
print("Usage: fix-exception-logging.py <file_or_dir> ...")
return 1
total_files = 0
total_fixes = 0
for filepath in sorted(paths):
changed, fixes = fix_file(filepath)
if changed:
total_files += 1
total_fixes += fixes
print(f" Fixed {filepath} ({fixes} changes)")
if total_files:
print(f"\nFixed {total_fixes} issues in {total_files} files")
else:
print("No issues to fix")
return 0
if __name__ == "__main__":
sys.exit(main())