Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/check-no-asserts.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions aboutcode
Submodule aboutcode added at 635026
59 changes: 59 additions & 0 deletions scripts/check_no_asserts.py
Original file line number Diff line number Diff line change
@@ -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())
95 changes: 95 additions & 0 deletions tools/replace_asserts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""A small codemod to replace simple `assert` statements with explicit raises.

This transformer replaces `assert <test>, <msg>` with:

if not <test>:
raise RuntimeError(<msg>)

and `assert <test>` 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 <test>: 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())