Filter concepts by levelShowing all levels.

Python · Code Quality and Tooling

Static analysis

Concepts
2

mypy and pyright catch type errors before code runs; a security linter (bandit) catches dangerous patterns — both without executing a single line.

Checking without running

Static type checking and security scanning — both analyze source code without executing it, catching different classes of problems.

mypy and pyright

coreintermediate

mypy and pyright are both static type checkers — they read a file's type hints and report type errors without running any code. mypy is the original checker; pyright is Microsoft's, also powering VS Code's Pylance.

Think of it as

A type checker is a proofreader that only checks whether the types promised (int, str, list[User]) are actually consistent throughout the file — it catches "you said this returns a string, but this line returns a number," entirely without running the program.

python
mypy myfile.py
pyright myfile.py

What we're doing: Run both mypy and pyright on the same real type error and compare their exact output.

typed.pypython
def calculate_total(price: float, quantity: int) -> float:
    return price * quantity


result: str = calculate_total(10.0, 3)
1
The return type annotation -> float is what both checkers compare the assignment on line 4 against.
4
result is declared str, but calculate_total returns float — both checkers catch this mismatch, worded differently.
Output
mypy:
typed.py:4: error: Incompatible types in assignment (expression has type "float", variable has type "str")  [assignment]
Found 1 error in 1 file (checked 1 source file)

pyright:
typed.py:5:15 - error: Type "float" is not assignable to declared type "str"
    "float" is not assignable to "str" (reportAssignmentType)
1 error, 0 warnings, 0 informations

Why this works: Both checkers catch exactly the same real bug — assigning a float to a str-annotated variable — but format the message differently, use different error-code naming ([assignment] vs. reportAssignmentType), and pyright additionally reports the exact column, not just the line.

Assuming a passing type check means the code has no bugs

Wrong

python
def get_user(user_id: int) -> dict:
    return {}   # type checks fine -- but is this ACTUALLY correct?

# mypy/pyright pass -- neither verifies the function's LOGIC is right

Better

python
def get_user(user_id: int) -> dict:
    return {}   # type checker still passes -- but a REAL TEST catches this

def test_get_user_returns_populated_dict():
    assert get_user(1) != {}   # a test checks actual behavior

What you see: A function returns an empty or wrong value while type-checking cleanly, because the annotated shape was technically correct even though the actual implementation is broken.

Why: A type checker only verifies that types are internally consistent — it has no idea whether the VALUES a function actually returns are correct. That is what tests are for; type checking and testing catch different classes of bugs.

Same bug, two checkers, different message shape

mypy

  • +Written in Python
  • +file:line: error: <message> [error-code]
  • +Checks fully only functions that are already typed

pyright

  • Written in TypeScript — tends to run faster
  • file:line:col - error: <message> (reportErrorCode)
  • Also powers VS Code's Pylance, live in the editor
  • mypy
    • Written in Python
    • file:line: error: <message> [error-code]
    • Checks fully only functions that are already typed
  • pyright
    • Written in TypeScript — tends to run faster
    • file:line:col - error: <message> (reportErrorCode)
    • Also powers VS Code's Pylance, live in the editor

Same type error, two checkers' output shape

Same type error, two checkers' output shape
CheckerError format
mypyfile:line: error: <message> [error-code]
pyrightfile:line:col - error: <message> (reportErrorCode)

Together

python
def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

result: str = calculate_total(10.0, 3)   # wrong type assigned

Remember: mypy and pyright both catch type errors statically, same syntax, different message format — neither replaces real tests.

See also: ruff and flake8 · mypy vs pyright

Security linters

coreintermediate

A security linter (bandit is the standard one for Python) scans source code for common vulnerability patterns — shell injection, hardcoded passwords, insecure subprocess use — that a style linter like Ruff never checks for.

Think of it as

A style linter checks whether code looks right. A security linter checks whether code is DANGEROUS — subprocess.call(x, shell=True) is perfectly valid Python, styled correctly, and still a serious vulnerability if x comes from user input.

python
bandit myfile.py
bandit -r .   # scan a whole project directory

What we're doing: Run bandit on real, deliberately insecure code and see it catch a genuine command-injection vulnerability.

insecure.pypython
import subprocess

password = "hardcoded_secret_123"


def run_command(user_input):
    subprocess.call(user_input, shell=True)
3
A string literal assigned to a variable named "password" — bandit flags this pattern as a possible hardcoded credential.
7
shell=True with an argument that could come from outside the function is a real command-injection vector — bandit rates this High severity.
Output
>> Issue: [B404:blacklist] Consider possible security implications associated with the subprocess module.
   Severity: Low   Confidence: High

>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hardcoded_secret_123'
   Severity: Low   Confidence: Medium

>> Issue: [B602:subprocess_popen_with_shell_equals_true] subprocess call with shell=True identified, security issue.
   Severity: High   Confidence: High

Why this works: run_command passes user_input straight into a shell command — if user_input ever contains something like "; rm -rf /", the shell executes it as a real command, not just data. bandit catches this exact pattern (B602) at High severity, something a style linter has no concept of checking.

Assuming a clean Ruff/Flake8 run means the code is secure

Wrong

python
# ruff check passes cleanly -- properly formatted, no unused imports
subprocess.call(user_input, shell=True)   # but this is a REAL vulnerability

Better

python
subprocess.call(["ls", user_input], shell=False)   # args as a list, no shell
# and run bandit as its own separate CI step

What you see: A genuine security vulnerability ships to production because the CI pipeline only runs a style linter, which has no rules for injection risks or hardcoded secrets.

Why: Ruff and Flake8 check for style and common bugs (unused variables, bad imports) — neither has any concept of "dangerous pattern." A security linter is a genuinely different tool checking a genuinely different category of problem, and needs to run as its own step.

What a style linter never checks

ruff / flake8

style, unused code — clean pass

bandit

B602 shell=True: High severity

bandit

B105 hardcoded password: Low

  • ruff / flake8 — style, unused code — clean pass
  • bandit — B602 shell=True: High severity
  • bandit — B105 hardcoded password: Low

Common bandit findings

Common bandit findings
CodeFinding
B602subprocess call with shell=True — command injection risk
B105possible hardcoded password string
B404import of the subprocess module (informational, low severity)
B301use of pickle — can execute arbitrary code on untrusted input

Together

python
import subprocess

def run_command(user_input):
    subprocess.call(user_input, shell=True)   # B602: High severity

Remember: A security linter checks for dangerous PATTERNS that a style linter has no concept of — run it as its own step.

See also: ruff and flake8

Advertisement