CodeSnake
Semantic code checker for Python 3. It parses files into an AST, walks them, and reports security problems, common bugs, unused names, and complexity smells.
Two properties set it apart from the fast general-purpose linters:
- It never imports or executes the code it analyzes. Everything runs on the AST from
ast.parse, so pointing it at untrusted Python — a fork's pull request, a submitted plugin — does not run that Python. - It has no runtime dependencies. Standard library only, so it vendors cleanly, works air-gapped, and adds nothing to your supply chain.
It also does light taint tracking: eval() on a literal is info, eval() on something derived from input() or request.args is an error. See how it compares to Ruff, Bandit, and pylint.
Requires Python 3.10+.
Install
python3 -m venv codesnake-venv
source codesnake-venv/bin/activate
pip install -e .
Optional companion tools (pylint, flake8, mypy, bandit, isort):
pip install -e ".[tools]"
Or run ./setup.sh, which creates the venv and installs from pyproject.toml.
After an editable install, codesnake is on PATH. You can also run:
python -m codesnake check file.py # after install
PYTHONPATH=src python -m codesnake file.py # straight from a checkout
./codesnake.sh file.py # creates/activates codesnake-venv/
First run on an existing codebase
CodeSnake reports complexity, length, and unused-name findings by default, so the
first run on a mature codebase is loud — expect roughly ten warnings per file. That
is a backlog, not an emergency: only error severity fails the run. Start narrow and
widen when you are ready.
# 1. What would actually fail CI. Start here.
codesnake check --severity error src/
# 2. Snapshot everything else, so CI only fails on NEW findings.
codesnake check --update-baseline .codesnake-baseline.json src/
git add .codesnake-baseline.json
# 3. From now on, this is your CI command.
codesnake check --baseline .codesnake-baseline.json src/
Then tune thresholds in .codesnake.json and shrink the baseline as you go. The full
adoption path is in docs/INTEGRATIONS.md.
Usage
# Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
codesnake check src/codesnake/checker.py test/example_bad_code.py
codesnake check src/
# Also analyze untracked files that .gitignore hides, e.g. generated output
codesnake check --no-ignore src/
# Same thing without the subcommand
codesnake src/
# JSON for CI
codesnake check --format json --no-color src/
# Errors only
codesnake check --severity error src/
# Merge Bandit findings (needs pip install -e ".[tools]")
codesnake check --bandit src/
# Only Python files staged in git
codesnake check --staged
# Snapshot findings, then fail only on new ones
codesnake check --update-baseline .codesnake-baseline.json src/
codesnake check --baseline .codesnake-baseline.json src/
# Custom thresholds
codesnake check --config .codesnake.json src/
# Write a default config file (refuses to overwrite; pass --force to replace)
codesnake config -o .codesnake.json
CLI flags
| Flag | Meaning |
|---|---|
--config PATH |
.codesnake.json or a pyproject.toml with [tool.codesnake] (otherwise the nearest one found walking up to the repository root, else defaults) |
--format text|json|github|sarif |
Report format (default text) |
--severity error|warning|info |
Minimum severity to print |
--no-color |
Disable ANSI color (NO_COLOR also works) |
--bandit |
Merge Bandit results when the bandit executable is installed |
--staged |
Check git diff --cached Python files only |
--no-ignore |
When walking directories, ignore .gitignore entirely, including for untracked files (venvs, caches, and .git are still skipped). Tracked files are analyzed either way. |
--baseline FILE |
Hide issues whose fingerprint is already in the baseline |
--update-baseline FILE |
Write the current finding set as a baseline |
-j, --jobs N |
Worker processes (default: auto — one per CPU once 8+ files are checked; 1 disables) |
--staged needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged .py files it exits 0. Directory walks skip .gitignore matches only for files git does not track, mirroring git's own behavior — so a file committed with git add -f is still analyzed, and no flag is needed to catch it. --no-ignore additionally covers untracked ignored files, such as generated output. --baseline fingerprints are filename|code|message-with-numbers-normalized|occurrence, so line-only edits and count changes (52 lines long → 53 lines long) do not re-fail CI, while a second identical violation in the same file still does. Version-1 baselines are read transparently; --update-baseline writes version 2. A missing baseline file fails closed (exit 1).
Output formats
--format |
Use |
|---|---|
text (default) |
Human-readable; color when stdout is a TTY; includes a one-line suggestion |
json |
Per-file issues plus a summary (end_line, end_col, suggestion, source) |
github |
GitHub Actions workflow commands (::error file=...,line=...,col=...,title=...::message), properly %-escaped |
sarif |
SARIF 2.1.0 with rule metadata (helpUri, default level, suggestion) and repo-relative URIs for code-scanning dashboards |
Exit codes
| Code | Meaning |
|---|---|
0 |
No error-severity issues (or --staged with no staged Python files) |
1 |
At least one error, I/O failure, syntax error, bad config/baseline, or git failure |
2 |
CLI usage error (unknown flag, or check with neither files nor --staged) |
Missing files, empty directories, and decode failures are IO001. Syntax errors are SYN001. Those always fail the run; they are never printed as “no issues found.”
What it checks
| Code | Severity | What |
|---|---|---|
| SEC001 | info / error | eval() / exec() — info on a constant, error on untrusted input |
| SEC002 | warning | Unsafe deserialization: pickle / dill / cloudpickle / jsonpickle loads, pickle.Unpickler(...).load(), marshal.load(s), shelve.open, and yaml.load without a safe Loader |
| SEC003 | warning / error | subprocess with shell=True, or os.system / os.popen / subprocess.getoutput (error if the command is untrusted) |
| SEC004 | warning | subprocess command (run, call, Popen, check_call, check_output) built from untrusted input |
| BUG001 | error | Mutable default arguments ([], {}, set(), list(), kw-only, lambda, async def) |
| BUG002 | warning | Duplicate key in a dict literal, including tuple keys |
| EXC001 | warning | Bare except: |
| EXC002 | info | except Exception, including builtins.Exception and tuple clauses like except (ValueError, Exception) |
| EXC003 | warning | Empty except body (pass) |
| EXC004 | warning | raise Exception() with no message |
| EXC005 | warning | raise NewError(...) inside except / except* without from |
| COMP001 | warning | Too many parameters |
| COMP002 | warning | Cyclomatic complexity too high (nested functions are not charged to the parent) |
| COMP003 | warning | Function longer than the configured maximum |
| COMP004 | warning | Class with too many methods |
| COMP005 | warning | Too many self.* assignments in __init__ (method calls are ignored) |
| PERF001 | info | for i in range(len(...)) |
| STYLE001 | info | is True / is False |
| IMP001 | warning | from module import * |
| IMP002 | warning | Imported name is never used (module level or inside a function) |
| IMP003 | error | Relative import of a name the sibling module does not define |
| VAR001 | warning | Unused local or nested function (loop targets, tuple unpacking, bare annotations, and decorated nested functions are exempt) |
| VAR002 | warning | Unused argument (self / cls, _-prefixed names, *args / **kwargs, lambda and dunder-method parameters, and abstract/stub bodies are skipped) |
| VAR003 | info | Local name shadows an enclosing function binding |
| REL002 | info | assert is stripped under -O (skipped in test_*.py, *_test.py, conftest.py, and test(s)/ directories) |
| RES001 | warning | open() used without with (anything inside a with item, contextlib.closing(...), or stack.enter_context(...) counts as owned) |
| ASY001 | warning | async def that never awaits (stubs and @abstractmethod skipped) |
| SYN001 | error | Syntax error |
| IO001 | error | File missing, not a file, unreadable, or empty directory |
| B### | varies | Bandit test ids, only when --bandit / use_bandit is on (source: bandit) |
Call checks resolve imports (from subprocess import call, import pickle as pkl) instead of matching only the AST shape. shell=True is also detected via a local or module constant (shell = True; run(..., shell=shell)); reassigning the name invalidates the constant.
Function bodies are analyzed after the enclosing scope is fully bound, so a closure that references a variable assigned after the def does not produce a false "unused variable" warning.
Untrusted input (taint) is tracked from input(), sys.argv, os.environ / os.getenv, and args / GET / POST / json / form-style attributes read from a request object (request, req, self.request), including f-strings, +, .format(), .get(), and subscripts. Passing tainted data through shlex.quote, int(), re.escape, html.escape, or urllib.parse.quote clears the taint.
IMP003 runs when several files are checked together. from .foo import bar is an error only if foo.py (or foo/__init__.py) is in the same run and does not define bar. Unused imports inside if TYPE_CHECKING: and names listed in __all__ are not flagged as IMP002.
Suppressing findings
On the same line as the issue:
eval("1+1") # noqa
eval("1+1") # noqa: SEC001
import os # codesnake: ignore
import os # codesnake: ignore=IMP002
# noqa with no codes suppresses every finding on that line.
Configuration
CodeSnake looks for configuration starting in the current directory and walking up to the repository root (the first directory containing .git). In each directory a .codesnake.json wins over a pyproject.toml [tool.codesnake] table. --config PATH (JSON or TOML) overrides discovery.
# pyproject.toml — same keys as the JSON file (reading TOML needs Python 3.11+)
[tool.codesnake]
max_complexity = 8
check_style = false
{
"max_function_length": 50,
"max_function_params": 7,
"max_complexity": 10,
"max_class_methods": 20,
"max_instance_vars": 10,
"check_security": true,
"check_bugs": true,
"check_exceptions": true,
"check_complexity": true,
"check_performance": true,
"check_imports": true,
"check_style": true,
"check_unused": true,
"check_reliability": true,
"use_bandit": false,
"report_errors": true,
"report_warnings": true,
"report_info": true
}
max_* thresholds must be 1 or greater; anything lower is a config error. check_* turns whole categories off (check_reliability covers REL002 and ASY001). report_* filters by severity. use_bandit merges Bandit when it is installed. A stricter sample lives in examples/strict.codesnake.json.
Library API
from codesnake import CheckerConfig, SemanticChecker, check_file, run_check
issues = SemanticChecker(source, filename="app.py").analyze()
issues = check_file("app.py") # I/O failures become IO001
issues = check_file("app.py", source=text) # analyze already-read text
config = CheckerConfig(max_complexity=8, check_style=False)
rc = run_check(
["app.py", "pkg/"],
config=config,
output_format="json",
min_severity="warning",
color=False,
staged=False,
baseline_path=".codesnake-baseline.json",
use_bandit=False,
)
Each Issue includes line, col, end_line, end_col, suggestion, and source (codesnake or bandit). col / end_col are 0-based character offsets (AST byte offsets are converted), and JSON output reports them as-is. The text, github, and sarif formats print 1-based columns.
How it compares
CodeSnake is not trying to replace Ruff. Use both.
| CodeSnake | Ruff | Bandit | pylint | |
|---|---|---|---|---|
| Speed, 167 stdlib files | 1.7s | 0.14s | 9.1s | 17.4s |
| Rules | ~25 | 800+ | ~70 security | 400+ |
| Runtime dependencies | none | none (Rust binary) | several | several |
| Imports the analyzed code | never | never | never | in some modes |
| Taint tracking | yes | no | limited | no |
| Autofix | no | yes | no | no |
| SARIF output | yes | no | yes | no |
| Baselines | yes | no | via --baseline |
no |
Ruff is roughly 13x faster and has 30x the rules. If you want one fast general-purpose linter with autofix, use Ruff — CodeSnake is not competing for that job. Among the Python-implemented checkers, though, CodeSnake is the quick one: about 5x faster than Bandit and 10x faster than pylint on the same files.
Measured on Python 3.12, best of 2–3 runs over the same 167 files from the standard
library, each tool using its own parallelism where it has any (codesnake auto,
pylint -j 0). pylint ran with --disable=all --enable=W,E, a reduced rule set in its
favor. Your numbers will differ; the ranking is the point, not the digits.
CodeSnake is worth adding when you want one of these:
- Taint tracking.
eval(x)wherexcame frominput()orrequest.argsis anerror;eval("1+1")isinfo. The fast linters flag the call site without asking where the data came from. - Analysis of untrusted code. No import, no execution, no dependencies — safe to run over a fork's PR or a user-submitted plugin.
- A vendorable checker. One pure-Python package with an empty dependency list, auditable in an afternoon, no toolchain.
- SARIF plus stable baselines, for GitHub code scanning on a codebase with an existing backlog.
Bandit has far broader security coverage; --bandit merges its findings into the same
report if you want both.
Performance
Files are analyzed in a process pool once there are 8 or more of them (one worker per CPU); pass --jobs 1 for a strictly sequential run or --jobs N to pin the count. Output order is always the input order.
Roughly 100 files/second single-process on a modern laptop. CodeSnake is pure Python doing a full AST walk per file; if analysis time dominates your CI, reach for Ruff.
Tests
python -m unittest discover -s test -p 'test_*.py'
python test/test_codesnake.py # same suite, with a summary
./codesnake-launcher.sh --no-venv --test
CI (.github/workflows/ci.yml) runs the suite on Python 3.10–3.13 and then runs codesnake check --format github src/ on the checker's own source.
test/example_bad_code.py is a fixture with intentional issues:
codesnake check test/example_bad_code.py
Project layout
codesnake/
├── pyproject.toml # packaging, extras, console script
├── LICENSE # MIT
├── .codesnake.json # default checker config
├── setup.sh # venv + editable install
├── codesnake.sh # simple launcher
├── codesnake-launcher.sh # flags, --test, --create-venv, --no-venv
├── src/codesnake/
│ ├── __init__.py # public API, __version__
│ ├── __main__.py # python -m codesnake
│ ├── _version.py # the one place the version lives
│ ├── checker.py # SemanticChecker, config, discovery, formats, run_check
│ ├── cli.py # check / config / version
│ └── banner.py
├── test/ # unittest suite + fixture
├── examples/ # strict.codesnake.json
├── docs/ # INTEGRATIONS, BASH_SCRIPTS_GUIDE, PROJECT_STRUCTURE
└── .github/workflows/ci.yml
Releasing: bump __version__ in src/codesnake/_version.py; pyproject.toml reads it dynamically.
Further reading
docs/INTEGRATIONS.md— pre-commit hooks, GitHub Actions (annotations, SARIF upload, baselines), VS Code, Makefile, adopting CodeSnake on an existing codebase, and how it overlaps with flake8/Bandit/pylint.docs/BASH_SCRIPTS_GUIDE.md—setup.sh,codesnake.sh, andcodesnake-launcher.sh, and the virtual environment they manage.docs/PROJECT_STRUCTURE.md— module map, how to add a rule, how to release.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file codesnake-1.3.0.tar.gz.
File metadata
- Download URL: codesnake-1.3.0.tar.gz
- Upload date:
- Size: 64.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5fb87e819cf070fd155e8c24c8b87f4d032dbc0a7594b081637f1b894d147cc
|
|
| MD5 |
f7d7d2adc0f920a0cbd25d93ab8b6308
|
|
| BLAKE2b-256 |
8e95d3b9f4649856fd68b3ff9573ee01c352cbe1ed5f78c85fca7faa90797dc5
|
Provenance
The following attestation bundles were made for codesnake-1.3.0.tar.gz:
Publisher:
release.yml on bitWarrior/codesnake
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
codesnake-1.3.0.tar.gz -
Subject digest:
f5fb87e819cf070fd155e8c24c8b87f4d032dbc0a7594b081637f1b894d147cc - Sigstore transparency entry: 2703083477
- Sigstore integration time:
-
Permalink:
bitWarrior/codesnake@7d45e8dac60649d989bf852027f6be5c64d0b9d3 -
Branch / Tag:
refs/tags/v1.3.0 - Owner: https://github.com/bitWarrior
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7d45e8dac60649d989bf852027f6be5c64d0b9d3 -
Trigger Event:
release
-
Statement type:
File details
Details for the file codesnake-1.3.0-py3-none-any.whl.
File metadata
- Download URL: codesnake-1.3.0-py3-none-any.whl
- Upload date:
- Size: 40.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6344585ef7df846af873b90e2c4a0134b1b942e2f86d0cf9ba5e3467023bf698
|
|
| MD5 |
13bf361dbdb098e0833c9302f01b65b0
|
|
| BLAKE2b-256 |
511828400c7284e9d3f5d24eab8242cccc32bd834acd16dfb27ca30aa2fa3cf0
|
Provenance
The following attestation bundles were made for codesnake-1.3.0-py3-none-any.whl:
Publisher:
release.yml on bitWarrior/codesnake
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
codesnake-1.3.0-py3-none-any.whl -
Subject digest:
6344585ef7df846af873b90e2c4a0134b1b942e2f86d0cf9ba5e3467023bf698 - Sigstore transparency entry: 2703083682
- Sigstore integration time:
-
Permalink:
bitWarrior/codesnake@7d45e8dac60649d989bf852027f6be5c64d0b9d3 -
Branch / Tag:
refs/tags/v1.3.0 - Owner: https://github.com/bitWarrior
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7d45e8dac60649d989bf852027f6be5c64d0b9d3 -
Trigger Event:
release
-
Statement type: