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

500 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Pre-commit hook to detect resources (services, database sessions) that are
instantiated without context managers.
Services like DownloadService, LibraryRAGService, and LocalEmbeddingManager
hold resources (file handles, connections, models) that need to be released.
They should be used with context managers (with statements) to ensure cleanup.
Functions like get_auth_db_session() return raw sessions that require manual
cleanup. Use the auth_db_session() context manager instead.
Exceptions:
- Factory functions that return services (caller is responsible for cleanup)
- Services passed to other objects (dependency injection)
- try/finally with explicit close()
"""
import ast
import sys
from typing import List, Optional, Set, Tuple
# Services (classes) that require context manager usage
SERVICES_REQUIRING_CONTEXT = {
"DownloadService",
"LibraryRAGService",
"LocalEmbeddingManager",
}
# Functions returning resources that need cleanup
# Maps function name -> suggested context manager replacement
FUNCTIONS_REQUIRING_CONTEXT = {
"get_auth_db_session": "auth_db_session()",
"get_db_session": "get_user_db_session(username)",
}
# Files/patterns where direct instantiation is allowed
ALLOWED_PATTERNS = {
"tests/", # Test files often mock or need special handling
"test_", # Test files
"_test.py", # Test files
# The service implementations themselves
"download_service.py",
"library_rag_service.py",
"search_engine_local.py",
# Database implementation files
"auth_db.py",
"encrypted_db.py",
"db_utils.py", # Defines get_db_session itself
"session_context.py", # Stores session in g.db_session (cleaned by teardown)
"web/auth/decorators.py", # inject_current_user stores in g.db_session
}
def is_file_allowed(filename: str) -> bool:
"""Check if this file is allowed to use services without context managers."""
for pattern in ALLOWED_PATTERNS:
if pattern in filename:
return True
return False
def get_resource_name(node: ast.expr) -> Optional[Tuple[str, Optional[str]]]:
"""
Check if an expression is a resource that requires cleanup.
Returns:
Tuple of (resource_name, suggested_replacement) or None if not a resource.
For services, suggested_replacement is None (use 'with ServiceName(...) as var:').
For functions, suggested_replacement is the context manager to use instead.
"""
if not isinstance(node, ast.Call):
return None
func_name = None
# Check for Name() pattern (e.g., ServiceName() or get_auth_db_session())
if isinstance(node.func, ast.Name):
func_name = node.func.id
# Check for module.Name() pattern (e.g., module.ServiceName())
elif isinstance(node.func, ast.Attribute):
func_name = node.func.attr
if func_name is None:
return None
# Check if it's a service class
if func_name in SERVICES_REQUIRING_CONTEXT:
return (func_name, None)
# Check if it's a function returning a resource
if func_name in FUNCTIONS_REQUIRING_CONTEXT:
return (func_name, FUNCTIONS_REQUIRING_CONTEXT[func_name])
return None
class FunctionScopeAnalyzer(ast.NodeVisitor):
"""Analyze patterns within a function scope."""
def __init__(self):
self.close_vars: Set[str] = set() # Variables with try/finally close()
self.safe_with_lines: Set[int] = set() # Lines in with context
self.returned_vars: Set[str] = set() # Variables that are returned
self.passed_to_args_vars: Set[str] = (
set()
) # Variables passed as arguments
def visit_Try(self, node: ast.Try) -> None:
"""Find variables that are closed in finally blocks within this scope."""
if node.finalbody:
self._find_close_calls(node.finalbody)
self.generic_visit(node)
def _find_close_calls(self, stmts: List) -> None:
"""Recursively find .close() calls in a list of statements."""
for stmt in stmts:
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
call = stmt.value
if (
isinstance(call.func, ast.Attribute)
and call.func.attr == "close"
and isinstance(call.func.value, ast.Name)
):
self.close_vars.add(call.func.value.id)
# Recursively check nested try blocks in finally
elif isinstance(stmt, ast.Try):
self._find_close_calls(stmt.body)
for handler in stmt.handlers:
self._find_close_calls(handler.body)
self._find_close_calls(stmt.finalbody)
elif isinstance(stmt, ast.If):
self._find_close_calls(stmt.body)
self._find_close_calls(stmt.orelse)
def visit_With(self, node: ast.With) -> None:
"""Mark with statements that use resources as context managers."""
for item in node.items:
resource_info = get_resource_name(item.context_expr)
if resource_info:
self.safe_with_lines.add(item.context_expr.lineno)
self.generic_visit(node)
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
"""Mark async with statements that use resources as context managers."""
for item in node.items:
resource_info = get_resource_name(item.context_expr)
if resource_info:
self.safe_with_lines.add(item.context_expr.lineno)
self.generic_visit(node)
def visit_Return(self, node: ast.Return) -> None:
"""Track variables that are returned (factory function pattern)."""
if node.value and isinstance(node.value, ast.Name):
self.returned_vars.add(node.value.id)
# Also handle direct resource returns
if node.value and get_resource_name(node.value):
# This is a direct return of a resource - mark the line as safe
self.safe_with_lines.add(node.value.lineno)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
"""Track variables passed as arguments to other functions."""
# Check all arguments
for arg in node.args:
if isinstance(arg, ast.Name):
self.passed_to_args_vars.add(arg.id)
# Check keyword arguments
for kwarg in node.keywords:
if isinstance(kwarg.value, ast.Name):
self.passed_to_args_vars.add(kwarg.value.id)
self.generic_visit(node)
class ServiceContextChecker(ast.NodeVisitor):
"""Check for service instantiations that aren't properly managed."""
def __init__(self):
self.errors: List[Tuple[int, str]] = []
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
"""Analyze a function for improper service usage."""
self._check_function_body(node)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
"""Analyze an async function for improper service usage."""
self._check_function_body(node)
self.generic_visit(node)
def visit_Module(self, node: ast.Module) -> None:
"""Also check module-level code."""
# Create a fake function body from module-level statements
# that aren't inside functions
module_stmts = []
for stmt in node.body:
if not isinstance(
stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
):
module_stmts.append(stmt)
if module_stmts:
self._check_statements(module_stmts)
self.generic_visit(node)
def _check_function_body(self, func_node) -> None:
"""Check a function body for improper service usage."""
self._check_statements(func_node.body)
def _check_statements(self, statements: List[ast.stmt]) -> None:
"""Check a list of statements for improper service usage."""
# First, analyze this scope for patterns
analyzer = FunctionScopeAnalyzer()
for stmt in statements:
analyzer.visit(stmt)
# Now check each statement for service assignments
self._check_statements_recursive(
statements,
analyzer.close_vars,
analyzer.safe_with_lines,
analyzer.returned_vars,
analyzer.passed_to_args_vars,
)
def _check_statements_recursive(
self,
statements: List[ast.stmt],
close_vars: Set[str],
safe_with_lines: Set[int],
returned_vars: Set[str],
passed_to_args_vars: Set[str],
) -> None:
"""Recursively check statements for service assignments."""
for stmt in statements:
if isinstance(stmt, ast.Assign):
self._check_assign(
stmt,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.With):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.AsyncWith):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.If):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.For):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.While):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
elif isinstance(stmt, ast.Try):
self._check_statements_recursive(
stmt.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
for handler in stmt.handlers:
self._check_statements_recursive(
handler.body,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.orelse,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
self._check_statements_recursive(
stmt.finalbody,
close_vars,
safe_with_lines,
returned_vars,
passed_to_args_vars,
)
def _check_assign(
self,
node: ast.Assign,
close_vars: Set[str],
safe_with_lines: Set[int],
returned_vars: Set[str],
passed_to_args_vars: Set[str],
) -> None:
"""Check an assignment for improper resource usage."""
resource_info = get_resource_name(node.value)
if not resource_info:
return
resource_name, suggested_replacement = resource_info
# Get the variable name being assigned
var_name = None
if node.targets and isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
# Check if this is a safe instantiation
if node.value.lineno in safe_with_lines:
# Inside a with statement as context manager - safe
return
if var_name and var_name in close_vars:
# Has explicit try/finally with close() in the same function - acceptable
return
if var_name and var_name in returned_vars:
# Variable is returned (factory function pattern) - caller responsible
return
if var_name and var_name in passed_to_args_vars:
# Variable is passed to another function (dependency injection) - receiver responsible
return
# Not safe - flag this
if suggested_replacement:
# Function with a known replacement context manager
self.errors.append(
(
node.lineno,
f"{resource_name}() returns a resource that needs cleanup. "
f"Use 'with {suggested_replacement} as var:' instead.",
)
)
else:
# Service class
self.errors.append(
(
node.lineno,
f"{resource_name} should be used with a context manager "
f"('with {resource_name}(...) as var:') to ensure proper cleanup.",
)
)
def check_file(filename: str) -> bool:
"""Check a single Python file for service context manager usage."""
if not filename.endswith(".py"):
return True
if is_file_allowed(filename):
return True
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
print(f"Error reading {filename}: {e}")
return False
try:
tree = ast.parse(content, filename=filename)
# Check for service instantiations
checker = ServiceContextChecker()
checker.visit(tree)
if checker.errors:
print(f"\n{filename}:")
for line_num, error in checker.errors:
print(f" Line {line_num}: {error}")
return False
except SyntaxError:
# Skip files with syntax errors (let other tools handle that)
pass
except Exception as e:
print(f"Error parsing {filename}: {e}")
return False
return True
def main():
"""Main function to check all provided Python files."""
if len(sys.argv) < 2:
print("Usage: check-service-context-managers.py <file1> <file2> ...")
sys.exit(1)
files_to_check = sys.argv[1:]
has_errors = False
for filename in files_to_check:
if not check_file(filename):
has_errors = True
if has_errors:
print("\n" + "=" * 70)
print("Resource Leak Prevention: Use context managers for cleanup!")
print("=" * 70)
print("\n--- Services ---")
print("Services like DownloadService, LibraryRAGService, and")
print("LocalEmbeddingManager hold resources that need cleanup.")
print("\nTo fix, use context managers:")
print(" # Before (leaks resources):")
print(" service = DownloadService()")
print(" result = service.download(...)")
print()
print(" # After (proper cleanup):")
print(" with DownloadService() as service:")
print(" result = service.download(...)")
print("\n--- Database Sessions ---")
print("get_auth_db_session() returns a raw session that needs cleanup.")
print("Use auth_db_session() context manager instead:")
print()
print(" # Before (may leak on exception):")
print(" session = get_auth_db_session()")
print(" user = session.query(User).first()")
print(" session.close()")
print()
print(" # After (proper cleanup):")
print(" with auth_db_session() as session:")
print(" user = session.query(User).first()")
print()
print("get_db_session() is deprecated and leaks QueuePool connections.")
print("Use get_user_db_session(username) context manager instead:")
print()
print(" # Before (leaks pool connection — never closed):")
print(" session = get_db_session(username=username)")
print()
print(" # After (proper cleanup):")
print(" with get_user_db_session(username) as session:")
print(" settings = SettingsManager(session)")
print("\n--- Alternative: try/finally ---")
print(" service = DownloadService()")
print(" try:")
print(" result = service.download(...)")
print(" finally:")
print(" service.close()")
print("\nNote: Factory functions that return resources are allowed,")
print("as the caller is responsible for cleanup.")
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()