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
589 lines
16 KiB
Python
589 lines
16 KiB
Python
"""
|
|
Tests for the check-silent-cleanup pre-commit hook.
|
|
|
|
Ensures the hook correctly detects:
|
|
1. Silent exception handling in close() methods
|
|
2. Bare .close() calls in finally/except blocks
|
|
"""
|
|
|
|
import ast
|
|
import sys
|
|
from importlib import import_module
|
|
from pathlib import Path
|
|
|
|
# Add the pre-commit hooks directory to path
|
|
HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks"
|
|
sys.path.insert(0, str(HOOKS_DIR))
|
|
|
|
# Import the checker from the hook (must be after sys.path modification)
|
|
hook_module = import_module("check-silent-cleanup") # noqa: E402
|
|
SilentCleanupChecker = hook_module.SilentCleanupChecker
|
|
BareCloseInFinallyChecker = hook_module.BareCloseInFinallyChecker
|
|
|
|
|
|
class TestSilentCleanupChecker:
|
|
"""Tests for the SilentCleanupChecker AST visitor."""
|
|
|
|
def _check_code(self, code: str, filename: str = "src/module.py") -> list:
|
|
"""Helper to check code and return errors."""
|
|
tree = ast.parse(code)
|
|
checker = SilentCleanupChecker(filename)
|
|
checker.visit(tree)
|
|
return checker.errors
|
|
|
|
# --- Detection: should flag ---
|
|
|
|
def test_detects_except_exception_pass_in_close(self):
|
|
"""Should flag except Exception: pass inside close()."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
assert "Silent exception" in errors[0][1]
|
|
|
|
def test_detects_bare_except_pass_in_close(self):
|
|
"""Should flag bare except: pass inside close()."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
|
|
def test_detects_multiple_silent_blocks(self):
|
|
"""Should flag each silent block independently."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.session.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.engine.close()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 2
|
|
|
|
def test_reports_correct_line_number(self):
|
|
"""Should report the line of the except handler, not the try."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
x = 1
|
|
y = 2
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
assert errors[0][0] == 8 # "except Exception:" is line 8
|
|
|
|
# --- Allowances: should NOT flag ---
|
|
|
|
def test_allows_logger_warning_in_handler(self):
|
|
"""Should allow except blocks that log a warning."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
logger.warning("Failed to close client")
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_logger_exception_in_handler(self):
|
|
"""Should allow except blocks that use logger.exception."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
logger.exception("Close failed")
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_logger_debug_in_handler(self):
|
|
"""Should allow any logger.* call."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
logger.debug("Ignoring close error")
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_raise_in_handler(self):
|
|
"""Should allow except blocks that re-raise."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
raise
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_specific_exception_type(self):
|
|
"""Should not flag handlers for specific exception types (e.g. OSError)."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except OSError:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_ignores_non_close_methods(self):
|
|
"""Should not inspect methods other than close()."""
|
|
code = """
|
|
class Foo:
|
|
def cleanup(self):
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def shutdown(self):
|
|
try:
|
|
self.engine.stop()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_ignores_standalone_close_function(self):
|
|
"""A top-level function named close() should still be checked."""
|
|
code = """
|
|
def close():
|
|
try:
|
|
something.close()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
|
|
def test_close_with_no_try_except(self):
|
|
"""close() with no try/except should pass cleanly."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
self.client.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_close_using_safe_close(self):
|
|
"""close() using safe_close() should pass."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
from utilities.resource_utils import safe_close
|
|
safe_close(self.client, "client")
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_mixed_silent_and_logged(self):
|
|
"""Should flag only the silent block, not the logged one."""
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.session.close()
|
|
except Exception:
|
|
logger.warning("session close failed")
|
|
try:
|
|
self.engine.close()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
# The flagged line should be the second except (line 10)
|
|
assert errors[0][0] == 10
|
|
|
|
|
|
class TestFileExclusions:
|
|
"""Tests for file-level exclusion patterns via check_file()."""
|
|
|
|
SILENT_CLOSE = """\
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.x.close()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
|
|
def _write_and_check(self, tmp_path, subdir, filename):
|
|
"""Write SILENT_CLOSE to subdir/filename and run check_file."""
|
|
d = tmp_path / subdir
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
f = d / filename
|
|
f.write_text(self.SILENT_CLOSE)
|
|
return hook_module.check_file(str(f))
|
|
|
|
def test_allows_test_directory(self, tmp_path):
|
|
"""Should skip files under tests/."""
|
|
assert self._write_and_check(tmp_path, "tests", "foo.py") is True
|
|
|
|
def test_allows_test_prefix(self, tmp_path):
|
|
"""Should skip files starting with test_."""
|
|
assert (
|
|
self._write_and_check(tmp_path, "src", "test_something.py") is True
|
|
)
|
|
|
|
def test_allows_test_suffix(self, tmp_path):
|
|
"""Should skip files ending with _test.py."""
|
|
assert (
|
|
self._write_and_check(tmp_path, "src", "something_test.py") is True
|
|
)
|
|
|
|
def test_allows_llm_config(self, tmp_path):
|
|
"""Should skip config/llm_config.py (logging boundary)."""
|
|
assert (
|
|
self._write_and_check(tmp_path, "config", "llm_config.py") is True
|
|
)
|
|
|
|
def test_allows_rate_limiting_wrapper(self, tmp_path):
|
|
"""Should skip rate_limiting/llm/wrapper.py (logging boundary)."""
|
|
assert (
|
|
self._write_and_check(tmp_path, "rate_limiting/llm", "wrapper.py")
|
|
is True
|
|
)
|
|
|
|
def test_flags_regular_source_file(self):
|
|
"""Should flag violations in normal source files."""
|
|
tree = ast.parse(self.SILENT_CLOSE)
|
|
checker = SilentCleanupChecker("src/engine.py")
|
|
checker.visit(tree)
|
|
assert len(checker.errors) == 1
|
|
|
|
|
|
class TestCheckFileIntegration:
|
|
"""Integration tests for check_file()."""
|
|
|
|
def _write_and_check(self, tmp_path, code, filename="module.py"):
|
|
"""Write code to a temp file and run check_file on it.
|
|
|
|
Uses a subdirectory named 'src' to avoid matching exclusion
|
|
patterns like 'test_' that can appear in pytest's tmp_path.
|
|
We also monkeypatch check_file to use a clean path for pattern
|
|
matching while still reading from the real file.
|
|
"""
|
|
src_dir = tmp_path / "src"
|
|
src_dir.mkdir(exist_ok=True)
|
|
filepath = src_dir / filename
|
|
filepath.write_text(code)
|
|
# check_file matches ALLOWED_PATTERNS against the full path.
|
|
# pytest tmp dirs contain "pytest-" and "test_" which would match.
|
|
# Use the checker directly to bypass path-based exclusions.
|
|
import ast as _ast
|
|
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
tree = _ast.parse(content, filename=str(filepath))
|
|
checker = SilentCleanupChecker("src/" + filename)
|
|
checker.visit(tree)
|
|
return len(checker.errors) == 0
|
|
|
|
def test_clean_file_passes(self, tmp_path):
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
from utils import safe_close
|
|
safe_close(self.client, "client")
|
|
"""
|
|
assert self._write_and_check(tmp_path, code) is True
|
|
|
|
def test_violation_fails(self, tmp_path):
|
|
code = """
|
|
class Foo:
|
|
def close(self):
|
|
try:
|
|
self.client.close()
|
|
except Exception:
|
|
pass
|
|
"""
|
|
assert self._write_and_check(tmp_path, code) is False
|
|
|
|
def test_non_python_file_passes(self, tmp_path):
|
|
src_dir = tmp_path / "src"
|
|
src_dir.mkdir()
|
|
txt = src_dir / "readme.txt"
|
|
txt.write_text("not python")
|
|
assert hook_module.check_file(str(txt)) is True
|
|
|
|
def test_syntax_error_passes(self, tmp_path):
|
|
"""Files with syntax errors are skipped (other tools handle them)."""
|
|
src_dir = tmp_path / "src"
|
|
src_dir.mkdir()
|
|
bad = src_dir / "broken.py"
|
|
bad.write_text("def close(self:\n pass")
|
|
assert hook_module.check_file(str(bad)) is True
|
|
|
|
def test_nonexistent_file_fails(self):
|
|
assert hook_module.check_file("/nonexistent/path/module.py") is False
|
|
|
|
def test_empty_file_passes(self, tmp_path):
|
|
src_dir = tmp_path / "src"
|
|
src_dir.mkdir()
|
|
empty = src_dir / "empty.py"
|
|
empty.write_text("")
|
|
assert hook_module.check_file(str(empty)) is True
|
|
|
|
|
|
class TestIsSilentHandler:
|
|
"""Unit tests for _is_silent_handler static method."""
|
|
|
|
def _make_handler(self, body_code):
|
|
"""Parse an except handler from code."""
|
|
code = f"""
|
|
try:
|
|
pass
|
|
except Exception:
|
|
{body_code}
|
|
"""
|
|
tree = ast.parse(code)
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ExceptHandler):
|
|
return node
|
|
raise ValueError("No ExceptHandler found")
|
|
|
|
def test_pass_only_is_silent(self):
|
|
handler = self._make_handler(" pass")
|
|
assert SilentCleanupChecker._is_silent_handler(handler) is True
|
|
|
|
def test_multiple_pass_is_silent(self):
|
|
handler = self._make_handler(" pass\n pass")
|
|
assert SilentCleanupChecker._is_silent_handler(handler) is True
|
|
|
|
def test_logger_call_is_not_silent(self):
|
|
handler = self._make_handler(' logger.warning("fail")')
|
|
assert SilentCleanupChecker._is_silent_handler(handler) is False
|
|
|
|
def test_raise_is_not_silent(self):
|
|
handler = self._make_handler(" raise")
|
|
assert SilentCleanupChecker._is_silent_handler(handler) is False
|
|
|
|
def test_assignment_is_not_silent(self):
|
|
"""An assignment (any non-pass statement) is not silent."""
|
|
handler = self._make_handler(" x = 1")
|
|
assert SilentCleanupChecker._is_silent_handler(handler) is False
|
|
|
|
def test_print_call_is_not_silent(self):
|
|
"""A print() call (non-logger, non-pass) is not silent."""
|
|
handler = self._make_handler(' print("error")')
|
|
assert SilentCleanupChecker._is_silent_handler(handler) is False
|
|
|
|
|
|
class TestBareCloseInFinallyChecker:
|
|
"""Tests for the BareCloseInFinallyChecker AST visitor."""
|
|
|
|
def _check_code(self, code: str, filename: str = "src/module.py") -> list:
|
|
"""Helper to check code and return errors."""
|
|
tree = ast.parse(code)
|
|
checker = BareCloseInFinallyChecker(filename)
|
|
checker.visit(tree)
|
|
return checker.errors
|
|
|
|
# --- Detection: should flag ---
|
|
|
|
def test_detects_bare_close_in_finally(self):
|
|
"""Should flag obj.close() in a finally block."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
resource.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
assert "Bare .close()" in errors[0][1]
|
|
|
|
def test_detects_bare_close_in_except(self):
|
|
"""Should flag obj.close() in an except block."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
except Exception:
|
|
conn.close()
|
|
raise
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
|
|
def test_detects_bare_close_in_if_inside_finally(self):
|
|
"""Should flag obj.close() inside if-guard in finally."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
if resource is not None:
|
|
resource.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
|
|
def test_detects_bare_close_in_else_inside_finally(self):
|
|
"""Should flag obj.close() in else branch of if in finally."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
if skip:
|
|
pass
|
|
else:
|
|
resource.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
|
|
def test_detects_multiple_bare_closes(self):
|
|
"""Should flag each bare .close() independently."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
session.close()
|
|
engine.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 2
|
|
|
|
def test_reports_correct_line_number(self):
|
|
"""Should report the line of the .close() call."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
x = 1
|
|
resource.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
assert errors[0][0] == 6
|
|
|
|
# --- Allowances: should NOT flag ---
|
|
|
|
def test_allows_close_in_nested_try_except(self):
|
|
"""Should not flag .close() that's protected by its own try/except."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
try:
|
|
resource.close()
|
|
except Exception:
|
|
logger.warning("close failed")
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_safe_close(self):
|
|
"""Should not flag safe_close() calls."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
safe_close(resource, "resource")
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_close_outside_finally(self):
|
|
"""Should not flag .close() calls in normal code."""
|
|
code = """
|
|
resource = get_resource()
|
|
resource.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_close_with_args(self):
|
|
"""Should not flag .close(arg) — only no-arg .close()."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
resource.close(force=True)
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_non_close_method_in_finally(self):
|
|
"""Should not flag other method calls in finally."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
finally:
|
|
resource.shutdown()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_bare_except_handler_also_caught(self):
|
|
"""Should flag .close() in bare except: block."""
|
|
code = """
|
|
try:
|
|
do_work()
|
|
except:
|
|
conn.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 1
|
|
|
|
def test_allows_plt_close(self):
|
|
"""Should not flag plt.close() — matplotlib figure cleanup, not a resource."""
|
|
code = """
|
|
try:
|
|
make_chart()
|
|
except Exception:
|
|
plt.figure()
|
|
plt.text(0.5, 0.5, "error")
|
|
plt.savefig(path)
|
|
plt.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|
|
|
|
def test_allows_fig_close(self):
|
|
"""Should not flag fig.close() — matplotlib figure object."""
|
|
code = """
|
|
try:
|
|
make_chart()
|
|
finally:
|
|
fig.close()
|
|
"""
|
|
errors = self._check_code(code)
|
|
assert len(errors) == 0
|