Python#
SafeLint analyses Python source for the Holzmann "Power of Ten" safety rules, function length, nesting depth, cyclomatic complexity, error-handling discipline, hidden side effects, dataflow taint, and other classes of bug that style linters like ruff don't catch. Python is SafeLint's original target language and stays the most fully covered.
File extensions#
.py, .pyw. Both are picked up by safelint check (directory mode, --all-files mode, and the pre-commit hook). Notebook formats (.ipynb) are not yet registered.
Quick start#
pip install 'safelint[python]' # or: uv add 'safelint[python]'
safelint check src/ # lint a directory (git-modified files by default)
safelint check --all-files . # lint everything
safelint check --format json src/ # machine-readable for editors / CI
v2.0.0 ships every language grammar as an opt-in extra, the [python] extra installs tree-sitter-python alongside the engine. Plain pip install safelint installs only the engine and emits an install hint on first run.
Rules that fire on Python#
Python is in scope for every cross-language rule plus SAFE201 bare_except (shared with C++), SAFE301 global_state (shared with PHP), and SAFE302 global_mutation. Newly added in 2.4.0: SAFE105 no_recursion (enabled by default), SAFE309 dynamic_code_execution, and SAFE603 blanket_suppression (both disabled by default). The 1 JavaScript-family-only rule (SAFE305 wide_scope_declaration) and the 4 Java + Spring Boot only rules (SAFE901-904) are skipped automatically by the engine's per-language dispatch.
| Code | Rule | Notes for Python |
|---|---|---|
| SAFE101 | function_length |
Default cap 60 lines; count_mode supports lines / logical_lines / statements (Python-only mode). |
| SAFE102 | nesting_depth |
Counts if / for / while / with / try / match blocks. Default max 2. |
| SAFE103 | max_arguments |
Counts positional, keyword, *args, **kwargs separately. Excludes self / cls. Default cap 7. |
| SAFE104 | complexity |
Cyclomatic complexity, every if / elif / for / while / except / case / ternary / and / or adds one. Default cap 10. |
| SAFE105 | no_recursion |
Flags a function that calls itself directly, bare (fact(n-1)) or self/cls-qualified (self.walk(...)). other.walk(...) does not fire. Direct self-recursion only. Enabled by default at warning severity. |
| SAFE201 | bare_except |
Fires on except: with no exception type, catches KeyboardInterrupt and SystemExit. (Also registered for C++'s catch (...).) |
| SAFE202 | empty_except |
Fires on except: pass, except: ..., except: 0, except: "TODO". |
| SAFE203 | logging_on_error |
Requires a call to logger.{debug,info,warning,error,exception,critical} (or bare raise) in every except handler. |
| SAFE301 | global_state |
Python and PHP. Fires on the global keyword. With strict = true, fires on every declaration; default is "declaration + write". |
| SAFE302 | global_mutation |
Function-body writes that follow a global declaration. Reading a global doesn't fire. |
| SAFE303 | side_effects_hidden |
Functions named with a pure-prefix (calculate_, get_, is_, …) that secretly call open() / print() / input(). |
| SAFE304 | side_effects |
Any function calling an I/O primitive whose name doesn't signal I/O (no log_ / write_ / read_ / etc. infix). |
| SAFE309 | dynamic_code_execution |
Structural detection of eval / exec / compile / __import__ (bare or builtins.-qualified; model.eval() does not fire). Holzmann rule 8; complements SAFE801. Disabled by default. |
| SAFE401 | resource_lifecycle |
Tracked acquirer calls (open, connect, Lock, Pool, …) must be inside a with statement. |
| SAFE501 | unbounded_loops |
while True: with no break. Also fires on while <non-comparison>:, a heuristic that stays Python-only. |
| SAFE601 | missing_assertions |
Functions with fewer than min_assertions assert statements (default 1; set 2 for Holzmann rule 5's density). Disabled by default. |
| SAFE603 | blanket_suppression |
Flags bare # noqa, # type: ignore without a [code], and # pylint: disable=all (Holzmann rule 10). Scoped suppressions and safelint's own # nosafe are clean. Disabled by default. |
| SAFE701 | test_existence |
Every source file should have a matching test_<stem>.py under test_dirs. Disabled by default. |
| SAFE702 | test_coupling |
If you change src/foo.py, you must also change tests/test_foo.py in the same commit. Disabled by default. |
| SAFE801 | tainted_sink |
Function parameters / input() flowing into eval / exec / subprocess / cursor.execute. Disabled by default. |
| SAFE802 | return_value_ignored |
Bare calls to subprocess.run, f.write, socket.send, os.rename, etc., return value carries success/failure. Disabled by default. |
| SAFE803 | null_dereference |
config.get("k").strip(), dereferencing a call that can return None. Disabled by default. |
The 1 rule not registered for Python: SAFE305 wide_scope_declaration, JavaScript-only; Python has no var / let / const distinction.
Configuration#
SafeLint reads its config from [tool.safelint] in pyproject.toml, or from a standalone safelint.toml at the project root. Standalone wins when both are present.
pyproject.toml:
[tool.safelint]
mode = "ci" # "local" (fail-on=error) or "ci" (fail-on=warning)
ignore = ["SAFE701"] # rules suppressed project-wide
[tool.safelint.per_file_ignores]
"tests/**" = ["SAFE101", "SAFE601"] # tests routinely have longer functions
"migrations/**" = ["*"] # ignore everything under migrations/
[tool.safelint.rules.function_length]
max_lines = 80 # raise the default cap
count_mode = "logical_lines"
[tool.safelint.rules.tainted_sink]
enabled = true # opt into the dataflow rules
Standalone safelint.toml:
# Same content but drop the [tool.safelint] prefix
mode = "ci"
ignore = ["SAFE701"]
[per_file_ignores]
"tests/**" = ["SAFE101", "SAFE601"]
[rules.function_length]
max_lines = 80
See Configuration file for the full list of top-level keys and Rules reference for every per-rule option.
Framework presets#
Python source is Python source - the parser, tree, and rule logic are framework-agnostic. But the rule defaults (taint sinks, nullable methods, and which SAFE9xx rules are active) shift with the web framework. [tool.safelint.python] framework = "<name>" selects the preset, and an orthogonal pydantic = true boolean composes on top of any framework.
| Framework | When to pick it | What changes |
|---|---|---|
vanilla (default) |
Plain Python, libraries, CLIs, data pipelines | Stdlib-only defaults (the lists in the rules table above). The SAFE905-907 framework rules are disabled. |
django |
Django / Django REST Framework projects | Adds raw / extra / RawSQL / mark_safe / format_html / HttpResponse / HttpResponseRedirect / redirect / FileResponse / call_command / loads to SAFE801 sinks; treats .first() as nullable for SAFE803. Enables SAFE905-907. |
flask |
Flask / Werkzeug apps | Adds render_template_string / Markup / redirect / send_file / send_from_directory / make_response to SAFE801 sinks. Enables SAFE905 + SAFE907 (not SAFE906 - Flask has no mass-assignment idiom). |
fastapi |
FastAPI / Starlette apps | Adds text / HTMLResponse / Response / from_string / RedirectResponse / FileResponse to SAFE801 sinks. Enables SAFE905-907. |
pydantic = true (independent of framework) additively adds Pydantic's validation-skipping constructors model_construct / construct to the SAFE801 sinks and enables SAFE906 so an extra = "allow" model config fires. It composes with any framework, or stands alone in a vanilla project.
# pyproject.toml
[tool.safelint.python]
framework = "django"
pydantic = true
# Or standalone safelint.toml (no [tool.safelint] wrapper):
[python]
framework = "fastapi"
Explicit per-rule TOML config still wins over the preset; setting [tool.safelint.rules.tainted_sink] sinks = [...] overrides whatever the preset planted. The default framework is vanilla, so existing users with no [python] config see no behaviour change. Unknown framework names surface a safelint: warning: on stderr and fall back to vanilla.
The SAFE905-907 structural rules are enabled by the preset directly. The dataflow additions (the extra SAFE801 sinks, .first() nullable, pydantic's constructors) only extend the lists - the multi-language dataflow rules (tainted_sink, return_value_ignored, null_dereference) stay opt-in, exactly as with the Java Spring preset (a Python framework choice must not turn dataflow on for other languages in a polyglot repo). Enable them explicitly to use the framework sinks. As with every language, the intra-procedural tracker follows direct taint flows, not request.<attr> attribute chains.
Installing the Python extra#
v2.0.0 ships every language grammar, Python included, as an opt-in extra so projects only install what they actually lint:
pip install 'safelint[python]' # Python-only project
pip install 'safelint[python,javascript]' # Python + JS monorepo
pip install 'safelint[all]' # kitchen-sink
pip install safelint (no extras) installs only the engine. safelint will emit safelint: warning: skipping .py files, install with: pip install 'safelint[python]' on first run when it finds Python files but the grammar isn't installed. Heads-up for CI: in a Python-only project that pattern means every candidate file gets skipped, which fires the silent-failure guard and exits with code 2 plus the install hint embedded in the error, so CI / pre-commit can't accidentally report green on an un-linted run.
Pre-commit integration#
# .pre-commit-config.yaml
repos:
- repo: https://github.com/shelkesays/safelint
rev: v2.1.0 # pin to a release (use a recent tag; v2.1.0+ also unlocks the Java extra if you later add .java files)
hooks:
- id: safelint
# Every safelint hook needs an extra in v2.0.0+, including Python-only projects.
additional_dependencies: ['safelint[python]']
# Optional: scope to a directory
files: ^src/
The published hook spec sets types_or: [python, javascript, ts, tsx, java, rust, go, php, c, c++] and no files: filter, add your own files: / exclude: keys to scope it. Mixed-language projects compose extras: additional_dependencies: ['safelint[python,javascript]'] (or [all]).
Python-specific config keys#
Most rule options work uniformly across languages, but a few are Python-only:
[tool.safelint.rules.function_length],count_mode = "statements"(counts AST statement nodes) is Python-only. JavaScript files uselines(default) orlogical_lines.[tool.safelint.rules.global_mutation],strict = true(fire on everyglobaldeclaration regardless of write) is Python-only.[tool.safelint.rules.side_effects_hidden],pure_prefixesdefaults match Pythonsnake_case(calculate_,get_,is_,has_,find_). For mixed-language repos the same list applies to both, the substring check is case-insensitive.[tool.safelint.rules.resource_lifecycle],tracked_functions,extend_tracked_functions, andcleanup_patternsare Python-only keys. The JavaScript equivalent istracked_functions_javascript.[tool.safelint.rules.tainted_sink],sinks,sanitizers,sourcesdefault to Python's threat surface (eval,exec,subprocess, …). The_javascript-suffixed equivalents are independent lists.
Contributing#
Want to refine a rule's Python behaviour, add a Python-specific config option, or fix a Python parser edge case? See Adding a language for the architecture overview, or open an issue / PR against the main repo.