Skip to content
Python Basics: Inspect Source Structure with ast

Python Basics: Inspect Source Structure with ast

October 23, 2026

A maintenance script can inspect Python source without importing the module. The ast module turns source text into syntax nodes, which helps list definitions, inventory imports, and read small literal settings. These examples analyze short strings that you control; they never execute the analyzed source.

Prepare a scratch directory

These standalone examples were tested on Linux with Python 3.12.14. Use Python 3.9 or newer for the interfaces used here. Use Bash, a text editor, and the standard mktemp, rm, and rmdir utilities. No third-party packages are required. Run without -O so the example assertions remain enabled.

Create a fresh directory and keep using the same terminal:

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

Save each named file below as UTF-8. Do not name your script after a standard-library module.

1. List only top-level functions

Save as list_source_functions.py:

import ast

source = "def build_report():\n    def format_row():\n        return 'row'\n    return format_row()\n\nasync def upload_report():\n    pass\n"
tree = ast.parse(source, filename="report_sample.py")
for node in tree.body:
    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
        kind = "async" if isinstance(node, ast.AsyncFunctionDef) else "sync"
        print(f"{node.name}: {kind}, line {node.lineno}")
assert len(tree.body) == 2

Run it:

python3 -B list_source_functions.py

Output:

build_report: sync, line 1
upload_report: async, line 6

Walking tree.body visits statements directly inside the module. The nested format_row definition is deliberately absent. ast.walk would also reach nested nodes; choose the traversal according to whether you want an API outline or every definition. Parsing does not import report_sample.py or run its body.

2. Collect syntactic import references

Save as inventory_source_imports.py:

import ast

source = "import json as codec\nfrom pathlib import Path\nfrom .helpers import render\n"
tree = ast.parse(source)
imports = []
for node in ast.walk(tree):
    if isinstance(node, ast.Import):
        imports.extend(alias.name for alias in node.names)
    elif isinstance(node, ast.ImportFrom):
        module = "." * node.level + (node.module or "")
        imports.extend(f"{module}:{alias.name}" for alias in node.names)
for name in sorted(imports):
    print(name)
assert imports == ["json", "pathlib:Path", ".helpers:render"]

Run it:

python3 -B inventory_source_imports.py

Output:

.helpers:render
json
pathlib:Path

Aliases do not change the module name recorded in an Import node. ImportFrom keeps the relative level separately, so this inventory retains its leading dot. This is a source inventory, not a dependency resolver: dynamic imports, conditional execution, and package resolution require additional analysis.

3. Read a small literal without evaluating calls

Save as read_literal_settings.py:

import ast

settings = ast.literal_eval("{'attempts': 3, 'labels': ['build', 'test']}")
assert isinstance(settings, dict)
assert settings["attempts"] == 3
print("Attempts:", settings["attempts"])
print("Labels:", ", ".join(settings["labels"]))
try:
    ast.literal_eval("dict(attempts=3)")
except (ValueError, SyntaxError):
    print("Function call: rejected")
else:
    raise AssertionError("A function call was accepted")

Run it:

python3 -B read_literal_settings.py

Output:

Attempts: 3
Labels: build, test
Function call: rejected

literal_eval accepts supported literal structures, not arbitrary Python expressions or function calls. It does not validate your application schema: the dict and required fields still need checks. This example uses a fixed, tiny input, not a general configuration endpoint.

Parsing is not a security sandbox or a proof that code can run. Syntax trees and accepted grammar vary between Python versions, and sufficiently large or deeply nested inputs can exhaust resources even with literal_eval. Bound input size and complexity before analyzing external data; never replace these read-only examples with eval or exec on untrusted text.

Clean up

In the same terminal, remove only the named sources, return to the original directory, and remove the empty scratch directory:

rm -- list_source_functions.py inventory_source_imports.py read_literal_settings.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

If rmdir reports remaining files, inspect them instead of deleting unknown contents.

References

Acknowledgements

This post was created with assistance from AI.

Last updated on