diff --git a/.github/workflows/check-no-asserts.yml b/.github/workflows/check-no-asserts.yml new file mode 100644 index 0000000..fcc7527 --- /dev/null +++ b/.github/workflows/check-no-asserts.yml @@ -0,0 +1,21 @@ +name: Check no asserts + +on: + push: {} + pull_request: {} + +jobs: + no-asserts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install deps (if any) + run: | + python -m pip install --upgrade pip + - name: Run assert check + run: | + python scripts/check_no_asserts.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9295754..a637796 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,3 +89,27 @@ By contributing to AboutCode, you agree that your contributions will be licensed --- Thank you for contributing to AboutCode! Your efforts help make open source software safer and more transparent for everyone. + +## Runtime checks and `assert` + +Do not use Python `assert` statements for runtime input validation or enforcing library invariants. `assert` is intended for debugging and may be disabled when Python is run with `-O` or `-OO`, which can silently remove important checks. Instead: + +- Use explicit condition checks and `raise` a clear, appropriate exception (for example, `TypeError`, `ValueError`, or a project-specific exception). +- Include an informative message to help users and calling code understand why the check failed. + +Example: + +Bad: + +```py +assert items is not None +``` + +Good: + +```py +if items is None: + raise ValueError("items must not be None") +``` + +We include an automated check in CI to prevent accidental `assert` usage in non-test code. If you need help migrating existing `assert` uses, ask on the project chat or open a pull request describing the change. diff --git a/aboutcode b/aboutcode new file mode 160000 index 0000000..635026c --- /dev/null +++ b/aboutcode @@ -0,0 +1 @@ +Subproject commit 635026caea791d3d6f6fd80a97d96c66b6410403 diff --git a/scripts/check_no_asserts.py b/scripts/check_no_asserts.py new file mode 100644 index 0000000..ee12281 --- /dev/null +++ b/scripts/check_no_asserts.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Check for `assert` statements in non-test Python files. + +Exit code 1 if any `assert` nodes are found outside tests/docs. +""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +EXCLUDE_DIRS = {"tests", "test", "docs", "doc", ".venv", "venv", "build", "dist", "__pycache__"} + + +def is_excluded(path: Path) -> bool: + for part in path.parts: + if part in EXCLUDE_DIRS: + return True + return False + + +def find_asserts() -> list[tuple[Path, int, str]]: + results: list[tuple[Path, int, str]] = [] + for p in ROOT.rglob("*.py"): + if is_excluded(p): + continue + try: + src = p.read_text(encoding="utf-8") + except Exception: + continue + try: + tree = ast.parse(src) + except Exception: + continue + for node in ast.walk(tree): + if isinstance(node, ast.Assert): + try: + seg = ast.get_source_segment(src, node.test) or "" + except Exception: + seg = "" + results.append((p, node.lineno, seg)) + break + return results + + +def main() -> int: + found = find_asserts() + if not found: + print("No `assert` statements found in non-test Python files.") + return 0 + print("Found `assert` statements in non-test files:") + for p, lineno, seg in found: + print(f"{p}:{lineno}: assert {seg}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/replace_asserts.py b/tools/replace_asserts.py new file mode 100644 index 0000000..a29ccd6 --- /dev/null +++ b/tools/replace_asserts.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""A small codemod to replace simple `assert` statements with explicit raises. + +This transformer replaces `assert , ` with: + + if not : + raise RuntimeError() + +and `assert ` with a `RuntimeError('Assertion failed')`. + +Use with caution; inspect changes before committing. Supports a `--apply` flag. +""" +from __future__ import annotations + +import argparse +import ast +import shutil +from pathlib import Path +import sys + + +class AssertTransformer(ast.NodeTransformer): + def visit_Assert(self, node: ast.Assert) -> ast.AST: + # Build message + msg_node = node.msg if node.msg is not None else ast.Constant(value="Assertion failed") + # Construct raise RuntimeError(msg_node) + raise_call = ast.Raise( + exc=ast.Call(func=ast.Name(id="RuntimeError", ctx=ast.Load()), args=[msg_node], keywords=[]), + cause=None, + ) + # Construct if not : raise RuntimeError(...) + new_if = ast.If( + test=ast.UnaryOp(op=ast.Not(), operand=node.test), + body=[raise_call], + orelse=[], + ) + return ast.copy_location(new_if, node) + + +def transform_source(src: str) -> str: + tree = ast.parse(src) + transformer = AssertTransformer() + new_tree = transformer.visit(tree) + ast.fix_missing_locations(new_tree) + try: + new_src = ast.unparse(new_tree) + except AttributeError: + raise RuntimeError("Python version does not support ast.unparse; use Python 3.9+") + return new_src + + +def process_file(path: Path, apply: bool) -> bool: + src = path.read_text(encoding="utf-8") + tree = ast.parse(src) + has_assert = any(isinstance(n, ast.Assert) for n in ast.walk(tree)) + if not has_assert: + return False + new_src = transform_source(src) + if apply: + bak = path.with_suffix(path.suffix + ".bak") + shutil.copy(path, bak) + path.write_text(new_src, encoding="utf-8") + else: + print(f"Would modify: {path}") + return True + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser() + ap.add_argument("paths", nargs="*", help="Files or directories to scan") + ap.add_argument("--apply", action="store_true", help="Apply changes in-place (backups .bak)") + args = ap.parse_args(argv) + + if not args.paths: + args.paths = ["."] + + changed = False + for p in args.paths: + path = Path(p) + if path.is_dir(): + for f in path.rglob("*.py"): + if process_file(f, args.apply): + changed = True + elif path.is_file(): + if process_file(path, args.apply): + changed = True + if changed: + print("Processed files with `assert` statements.") + return 0 + print("No `assert` statements found to process.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())