Skip to content

Commit 87327e1

Browse files
committed
Add pylint docstring validator
Signed-off-by: Mathias L. Baumann <[email protected]>
1 parent cbf7f00 commit 87327e1

File tree

2 files changed

+213
-0
lines changed

2 files changed

+213
-0
lines changed

noxfile.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def pylint(session: nox.Session) -> None:
3333
".[docs]",
3434
"pylint",
3535
"pytest",
36+
"sybil",
3637
"nox",
3738
"async-solipsism",
3839
"hypothesis",
@@ -95,6 +96,8 @@ def pytest(session: nox.Session) -> None:
9596
"pytest-asyncio",
9697
"async-solipsism",
9798
"hypothesis",
99+
"sybil",
100+
"pylint",
98101
)
99102
session.install("-e", ".")
100103
session.run(

src/conftest.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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+
response_concats = "\n".join(response)
164+
return (
165+
f"Pylint validation failed for code example:\n"
166+
f"{example_with_imports}\nOutput: {response_concats}"
167+
)
168+
169+
return None
170+
171+
172+
def validate_with_pylint(
173+
code_example: str, path: str, disable_params: list[str]
174+
) -> list[str]:
175+
"""Validate a code example using pylint.
176+
177+
Args:
178+
code_example: The code example to validate.
179+
path: The path to the original file.
180+
disable_params: The pylint disable parameters.
181+
182+
Returns:
183+
A list of pylint messages.
184+
"""
185+
try:
186+
pylint_command = [
187+
"pylint",
188+
"--disable",
189+
",".join(disable_params),
190+
"--from-stdin",
191+
path,
192+
]
193+
194+
subprocess.run(
195+
pylint_command,
196+
input=code_example,
197+
text=True,
198+
capture_output=True,
199+
check=True,
200+
)
201+
except subprocess.CalledProcessError as exception:
202+
return exception.output.splitlines()
203+
204+
return []
205+
206+
207+
pytest_collect_file = Sybil(
208+
parsers=[CustomPythonCodeBlockParser()],
209+
patterns=["*.py"],
210+
).pytest()

0 commit comments

Comments
 (0)