Skip to content

Commit 31684ed

Browse files
committed
Add module to validate ``` python wrapped code examples in docstrings
Signed-off-by: Mathias L. Baumann <[email protected]>
1 parent 3edf6a5 commit 31684ed

File tree

2 files changed

+212
-0
lines changed

2 files changed

+212
-0
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ pytest = [
7575
"pytest-asyncio == 0.21.0",
7676
"time-machine == 2.9.0",
7777
"async-solipsism == 0.5",
78+
# For checking docstring code examples
79+
"sybil == 5.0.1",
80+
"pylint == 2.17.4",
7881
]
7982
mypy = [
8083
"mypy == 1.3.0",

src/conftest.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Pytest plugin to validate docstring code examples.
5+
6+
Code examples are often wrapped in triple backticks (```) within our docstrings.
7+
This plugin extracts these code examples and validates them using pylint.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import ast
13+
import os
14+
import subprocess
15+
from pathlib import Path
16+
17+
from sybil import Example, Sybil
18+
from sybil.evaluators.python import pad
19+
from sybil.parsers.abstract.lexers import textwrap
20+
from sybil.parsers.myst import CodeBlockParser
21+
22+
PYLINT_DISABLE_COMMENT = (
23+
"# pylint: {}=unused-import,wildcard-import,unused-wildcard-import"
24+
)
25+
26+
FORMAT_STRING = """
27+
# Generated auto-imports for code example
28+
{disable_pylint}
29+
{imports}
30+
{enable_pylint}
31+
32+
{code}"""
33+
34+
35+
def get_import_statements(code: str) -> list[str]:
36+
"""Get all import statements from a given code string.
37+
38+
Args:
39+
code: The code to extract import statements from.
40+
41+
Returns:
42+
A list of import statements.
43+
"""
44+
tree = ast.parse(code)
45+
import_statements = []
46+
47+
for node in ast.walk(tree):
48+
if isinstance(node, (ast.Import, ast.ImportFrom)):
49+
import_statement = ast.get_source_segment(code, node)
50+
import_statements.append(import_statement)
51+
52+
return import_statements
53+
54+
55+
def path_to_import_statement(path: Path) -> str:
56+
"""Convert a path to a Python file to an import statement.
57+
58+
Args:
59+
path: The path to convert.
60+
61+
Returns:
62+
The import statement.
63+
64+
Raises:
65+
ValueError: If the path does not point to a Python file.
66+
"""
67+
# Make the path relative to the present working directory
68+
if path.is_absolute():
69+
path = path.relative_to(Path.cwd())
70+
71+
# Check if the path is a Python file
72+
if path.suffix != ".py":
73+
raise ValueError("Path must point to a Python file (.py)")
74+
75+
# Remove 'src' prefix if present
76+
parts = path.parts
77+
if parts[0] == "src":
78+
parts = parts[1:]
79+
80+
# Remove the '.py' extension and join parts with '.'
81+
module_path = ".".join(parts)[:-3]
82+
83+
# Create the import statement
84+
import_statement = f"from {module_path} import *"
85+
return import_statement
86+
87+
88+
class CustomPythonCodeBlockParser(CodeBlockParser):
89+
"""Code block parser that validates extracted code examples using pylint.
90+
91+
This parser is a modified version of the default Python code block parser
92+
from the Sybil library.
93+
It uses pylint to validate the extracted code examples.
94+
95+
All code examples are preceded by the original file's import statements as
96+
well as an wildcard import of the file itself.
97+
This allows us to use the code examples as if they were part of the original
98+
file.
99+
100+
Additionally, the code example is padded with empty lines to make sure the
101+
line numbers are correct.
102+
103+
Pylint warnings which are unimportant for code examples are disabled.
104+
"""
105+
106+
def __init__(self):
107+
"""Initialize the parser."""
108+
super().__init__("python")
109+
110+
def evaluate(self, example: Example) -> None | str:
111+
"""Validate the extracted code example using pylint.
112+
113+
Args:
114+
example: The extracted code example.
115+
116+
Returns:
117+
None if the code example is valid, otherwise the pylint output.
118+
"""
119+
# Get the import statements for the original file
120+
import_header = get_import_statements(example.document.text)
121+
# Add a wildcard import of the original file
122+
import_header.append(
123+
path_to_import_statement(Path(os.path.relpath(example.path)))
124+
)
125+
imports_code = "\n".join(import_header)
126+
127+
# Dedent the code example
128+
# There is also example.parsed that is already prepared, but it has
129+
# empty lines stripped and thus fucks up the line numbers.
130+
example_code = textwrap.dedent(
131+
example.document.text[example.start : example.end]
132+
)
133+
# Remove first line (the line with the triple backticks)
134+
example_code = example_code[example_code.find("\n") + 1 :]
135+
136+
example_with_imports = FORMAT_STRING.format(
137+
disable_pylint=PYLINT_DISABLE_COMMENT.format("disable"),
138+
imports=imports_code,
139+
enable_pylint=PYLINT_DISABLE_COMMENT.format("enable"),
140+
code=example_code,
141+
)
142+
143+
# Make sure the line numbers are correct
144+
source = pad(
145+
example_with_imports,
146+
example.line - imports_code.count("\n") - FORMAT_STRING.count("\n"),
147+
)
148+
149+
# pylint disable parameters
150+
pylint_disable_params = [
151+
"missing-module-docstring",
152+
"missing-class-docstring",
153+
"missing-function-docstring",
154+
"reimported",
155+
"unused-variable",
156+
"no-name-in-module",
157+
"await-outside-async",
158+
]
159+
160+
response = validate_with_pylint(source, example.path, pylint_disable_params)
161+
162+
if len(response) > 0:
163+
return (
164+
f"Pylint validation failed for code example:\n"
165+
f"{example_with_imports}\nOutput: {response}"
166+
)
167+
168+
return None
169+
170+
171+
def validate_with_pylint(
172+
code_example: str, path: str, disable_params: list[str]
173+
) -> list[str]:
174+
"""Validate a code example using pylint.
175+
176+
Args:
177+
code_example: The code example to validate.
178+
path: The path to the original file.
179+
disable_params: The pylint disable parameters.
180+
181+
Returns:
182+
A list of pylint messages.
183+
"""
184+
try:
185+
pylint_command = [
186+
"pylint",
187+
"--disable",
188+
",".join(disable_params),
189+
"--from-stdin",
190+
path,
191+
]
192+
193+
subprocess.run(
194+
pylint_command,
195+
input=code_example,
196+
text=True,
197+
capture_output=True,
198+
check=True,
199+
)
200+
except subprocess.CalledProcessError as exception:
201+
return exception.output.splitlines()
202+
203+
return []
204+
205+
206+
pytest_collect_file = Sybil(
207+
parsers=[CustomPythonCodeBlockParser()],
208+
patterns=["*.py"],
209+
).pytest()

0 commit comments

Comments
 (0)