Skip to content

Commit bb07f51

Browse files
Add script to recursively execute doctest-style examples found in docstrings under a given root dir
1 parent 36d33c8 commit bb07f51

File tree

1 file changed

+143
-0
lines changed

1 file changed

+143
-0
lines changed

run_docstrings.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import ast
4+
import doctest
5+
import os
6+
import textwrap
7+
import traceback
8+
from pathlib import Path
9+
10+
11+
def iter_docstring_nodes(tree):
12+
"""
13+
Yield (expr_node, docstring_text) for all docstrings in the AST:
14+
- module docstring
15+
- class docstrings
16+
- function / async function docstrings
17+
"""
18+
for node in ast.walk(tree):
19+
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
20+
if not getattr(node, "body", None):
21+
continue
22+
first = node.body[0]
23+
if isinstance(first, ast.Expr):
24+
value = first.value
25+
# Python 3.8+: Constant; older: Str
26+
if isinstance(value, ast.Constant) and isinstance(value.value, str):
27+
yield first, value.value
28+
elif isinstance(value, ast.Str): # pragma: no cover (older Python)
29+
yield first, value.s
30+
31+
32+
def run_docstring_examples(docstring, file_path, doc_start_lineno, file_globals):
33+
"""
34+
Execute all doctest-style examples in a docstring.
35+
36+
- docstring: the string content of the docstring
37+
- file_path: absolute path to the file (for clickable tracebacks)
38+
- doc_start_lineno: 1-based line number where the docstring literal starts in the file
39+
- file_globals: globals dict shared for all examples in this file
40+
"""
41+
parser = doctest.DocTestParser()
42+
parts = parser.parse(docstring)
43+
44+
# Collect only actual doctest examples
45+
examples = [p for p in parts if isinstance(p, doctest.Example)]
46+
if not examples:
47+
# No code examples in this docstring -> do nothing
48+
return
49+
50+
abs_path = os.path.abspath(file_path)
51+
52+
for example in examples:
53+
code = example.source
54+
if not code.strip():
55+
continue
56+
57+
# example.lineno is the 0-based line index within the docstring
58+
# The docstring itself starts at doc_start_lineno in the file.
59+
file_start_line = doc_start_lineno + example.lineno
60+
61+
# Pad with newlines so that the first line of the example appears
62+
# at the correct line number in the traceback.
63+
padded_code = "\n" * (file_start_line - 1) + code
64+
65+
try:
66+
compiled = compile(padded_code, abs_path, "exec")
67+
exec(compiled, file_globals)
68+
except Exception:
69+
print("\n" + "=" * 79)
70+
print(f"Error while executing docstring example in: {abs_path}:{file_start_line}")
71+
print("-" * 79)
72+
print("Code that failed:\n")
73+
print(textwrap.indent(code.rstrip(), " "))
74+
print("\nStack trace:\n")
75+
traceback.print_exc()
76+
print("=" * 79)
77+
78+
79+
def process_file(path: Path):
80+
"""
81+
Parse a single Python file, extract docstrings, and run doctest-style examples.
82+
"""
83+
if not path.is_file() or not path.suffix == ".py":
84+
return
85+
86+
try:
87+
source = path.read_text(encoding="utf-8")
88+
except UnicodeDecodeError:
89+
# Non-text or weird encoding; skip
90+
return
91+
92+
try:
93+
tree = ast.parse(source, filename=str(path))
94+
except SyntaxError:
95+
# Invalid Python; skip
96+
return
97+
98+
# Shared globals per file: examples in the same file share state
99+
file_globals = {
100+
"__name__": "__doctest__",
101+
"__file__": str(path.resolve()),
102+
"__package__": None,
103+
}
104+
105+
for expr_node, docstring in iter_docstring_nodes(tree):
106+
# expr_node.lineno is the line where the string literal starts
107+
run_docstring_examples(
108+
docstring=docstring,
109+
file_path=str(path.resolve()),
110+
doc_start_lineno=expr_node.lineno,
111+
file_globals=file_globals,
112+
)
113+
114+
115+
def walk_directory(root: Path):
116+
"""
117+
Recursively walk a directory and process all .py files.
118+
"""
119+
for dirpath, dirnames, filenames in os.walk(root):
120+
for filename in filenames:
121+
if filename.endswith(".py"):
122+
process_file(Path(dirpath) / filename)
123+
124+
125+
def main():
126+
parser = argparse.ArgumentParser(
127+
description="Recursively execute doctest-style examples found in docstrings."
128+
)
129+
parser.add_argument("root", help="Root directory (or single .py file) to scan")
130+
args = parser.parse_args()
131+
132+
root = Path(args.root).resolve()
133+
if not root.exists():
134+
parser.error(f"{root} does not exist")
135+
136+
if root.is_file():
137+
process_file(root)
138+
else:
139+
walk_directory(root)
140+
141+
142+
if __name__ == "__main__":
143+
main()

0 commit comments

Comments
 (0)