Python Basics: Inspect Source Structure with ast
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 1Save 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) == 2Run it:
python3 -B list_source_functions.pyOutput:
build_report: sync, line 1
upload_report: async, line 6Walking 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.pyOutput:
.helpers:render
json
pathlib:PathAliases 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.pyOutput:
Attempts: 3
Labels: build, test
Function call: rejectedliteral_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_dirIf rmdir reports remaining files, inspect them instead of deleting unknown contents.
References
Acknowledgements
This post was created with assistance from AI.