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
194 lines
7.1 KiB
Python
Executable File
194 lines
7.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Pre-commit hook to detect get_settings_manager() calls without db_session
|
|
in code that runs in background threads.
|
|
|
|
Background threads have no Flask app context, so get_settings_manager()
|
|
without an explicit db_session falls back to JSON defaults only. Settings
|
|
that have no JSON defaults (e.g. local_search_* embedding keys) silently
|
|
return None, causing user-configured values to be ignored. See #3453.
|
|
|
|
Scope and limitations:
|
|
- Only direct calls inside a thread function are inspected. Indirect
|
|
calls via a helper function (thread fn -> helper -> get_settings_manager)
|
|
are not caught; that would require cross-function call-graph analysis.
|
|
- Thread detection relies on the @thread_cleanup decorator or
|
|
_background_/_auto_/*_worker naming conventions. Other thread targets
|
|
(e.g. threading.Thread(target=self._monitor_resources)) are not matched.
|
|
Prefer decorating those functions with @thread_cleanup if they need
|
|
to call get_settings_manager().
|
|
- Test files are excluded via .pre-commit-config.yaml ``exclude: ^tests/``.
|
|
"""
|
|
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Tuple
|
|
|
|
# Decorators that mark a function as running in a background thread
|
|
THREAD_DECORATORS = frozenset({"thread_cleanup"})
|
|
|
|
# Function name patterns that indicate background-thread execution
|
|
THREAD_FUNCTION_PREFIXES = ("_background_", "_auto_")
|
|
THREAD_FUNCTION_SUFFIXES = ("_worker",)
|
|
|
|
|
|
class SettingsManagerThreadSafetyChecker(ast.NodeVisitor):
|
|
"""AST visitor to detect unsafe get_settings_manager() calls in thread code."""
|
|
|
|
def __init__(self, filename: str):
|
|
self.filename = filename
|
|
self.issues: List[Tuple[int, str]] = []
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
if self._is_thread_function(node):
|
|
self._check_body_for_unsafe_calls(node)
|
|
self.generic_visit(node)
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
if self._is_thread_function(node):
|
|
self._check_body_for_unsafe_calls(node)
|
|
self.generic_visit(node)
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def _is_thread_function(self, node) -> bool:
|
|
"""Return True if the function is likely executed in a background thread."""
|
|
# Check decorators
|
|
for decorator in node.decorator_list:
|
|
name = None
|
|
if isinstance(decorator, ast.Name):
|
|
name = decorator.id
|
|
elif isinstance(decorator, ast.Attribute):
|
|
name = decorator.attr
|
|
elif isinstance(decorator, ast.Call):
|
|
if isinstance(decorator.func, ast.Name):
|
|
name = decorator.func.id
|
|
elif isinstance(decorator.func, ast.Attribute):
|
|
name = decorator.func.attr
|
|
if name and name in THREAD_DECORATORS:
|
|
return True
|
|
|
|
# Check function name conventions
|
|
fname = node.name
|
|
if any(fname.startswith(p) for p in THREAD_FUNCTION_PREFIXES):
|
|
return True
|
|
if any(fname.endswith(s) for s in THREAD_FUNCTION_SUFFIXES):
|
|
return True
|
|
|
|
return False
|
|
|
|
def _check_body_for_unsafe_calls(self, node) -> None:
|
|
"""Walk the function body looking for get_settings_manager() without db_session.
|
|
|
|
Stops at nested function-def boundaries: any nested function is
|
|
visited separately by ``generic_visit`` -> ``visit_FunctionDef``,
|
|
so we must not descend into it here or we would double-report.
|
|
"""
|
|
for child in self._iter_non_nested(node):
|
|
if not isinstance(child, ast.Call):
|
|
continue
|
|
if not self._is_get_settings_manager_call(child):
|
|
continue
|
|
if not self._has_db_session_arg(child):
|
|
self.issues.append(
|
|
(
|
|
child.lineno,
|
|
"get_settings_manager() called without db_session= "
|
|
"in a background-thread function. In threads without "
|
|
"Flask app context the DB session will be None and "
|
|
"settings fall back to JSON defaults only. "
|
|
"Pass an explicit db_session from get_user_db_session().",
|
|
),
|
|
)
|
|
|
|
@classmethod
|
|
def _iter_non_nested(cls, node):
|
|
"""Yield descendants of ``node``, stopping at nested function defs."""
|
|
for child in ast.iter_child_nodes(node):
|
|
if isinstance(
|
|
child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
|
|
):
|
|
continue
|
|
yield child
|
|
yield from cls._iter_non_nested(child)
|
|
|
|
@staticmethod
|
|
def _is_get_settings_manager_call(call_node: ast.Call) -> bool:
|
|
func = call_node.func
|
|
if isinstance(func, ast.Name) and func.id == "get_settings_manager":
|
|
return True
|
|
if (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == "get_settings_manager"
|
|
):
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _has_db_session_arg(call_node: ast.Call) -> bool:
|
|
# db_session is the first positional param of get_settings_manager,
|
|
# so any positional argument satisfies the safety contract.
|
|
if call_node.args:
|
|
return True
|
|
return any(kw.arg == "db_session" for kw in call_node.keywords)
|
|
|
|
|
|
def check_file(filepath: Path) -> List[Tuple[str, int, str]]:
|
|
"""Check a single Python file."""
|
|
issues = []
|
|
try:
|
|
content = filepath.read_text(encoding="utf-8")
|
|
tree = ast.parse(content, filename=str(filepath))
|
|
checker = SettingsManagerThreadSafetyChecker(str(filepath))
|
|
checker.visit(tree)
|
|
for line_no, message in checker.issues:
|
|
issues.append((str(filepath), line_no, message))
|
|
except SyntaxError as e:
|
|
print(f"Syntax error in {filepath}: {e}", file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"Error checking {filepath}: {e}", file=sys.stderr)
|
|
return issues
|
|
|
|
|
|
def main():
|
|
files_to_check = sys.argv[1:] if len(sys.argv) > 1 else []
|
|
if not files_to_check:
|
|
print("No files to check")
|
|
return 0
|
|
|
|
all_issues = []
|
|
for filepath_str in files_to_check:
|
|
filepath = Path(filepath_str)
|
|
if not filepath.suffix == ".py":
|
|
continue
|
|
issues = check_file(filepath)
|
|
all_issues.extend(issues)
|
|
|
|
if all_issues:
|
|
print(
|
|
"\n\u274c get_settings_manager() called without db_session "
|
|
"in background-thread code:\n"
|
|
)
|
|
for filepath, line_no, message in all_issues:
|
|
print(f" {filepath}:{line_no}: {message}")
|
|
print(
|
|
"\n\U0001f4a1 Tip: Use get_user_db_session() to obtain a session "
|
|
"and pass it explicitly:\n"
|
|
)
|
|
print(
|
|
" with get_user_db_session(username, db_password) as db_session:"
|
|
)
|
|
print(
|
|
" settings = get_settings_manager("
|
|
"db_session=db_session, username=username)"
|
|
)
|
|
print()
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|