Skip to content

Commit 0c5eac7

Browse files
authored
Validate docstring examples (#101)
- Fix examples in docstrings - Add pylint docstring validator
2 parents 27ec0ae + 87327e1 commit 0c5eac7

File tree

5 files changed

+255
-10
lines changed

5 files changed

+255
-10
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()

src/frequenz/channels/util/_merge.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ class Merge(Receiver[T]):
2020
stream, by using `Merge` like this:
2121
2222
```python
23+
from frequenz.channels import Broadcast
24+
25+
channel1 = Broadcast[int]("input-chan-1")
26+
channel2 = Broadcast[int]("input-chan-2")
27+
receiver1 = channel1.new_receiver()
28+
receiver2 = channel2.new_receiver()
29+
2330
merge = Merge(receiver1, receiver2)
2431
while msg := await merge.receive():
2532
# do something with msg

src/frequenz/channels/util/_select.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ class Select:
7272
simultaneously wait on, this can be done with:
7373
7474
```python
75+
from frequenz.channels import Broadcast
76+
77+
channel1 = Broadcast[int]("input-chan-1")
78+
channel2 = Broadcast[int]("input-chan-2")
79+
receiver1 = channel1.new_receiver()
80+
receiver2 = channel2.new_receiver()
81+
7582
select = Select(name1 = receiver1, name2 = receiver2)
7683
while await select.ready():
7784
if msg := select.name1:

src/frequenz/channels/util/_timer.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -287,43 +287,61 @@ class Timer(Receiver[timedelta]):
287287
with other receivers, and even start it (semi) manually:
288288
289289
```python
290+
import logging
291+
from frequenz.channels.util import Select
292+
from frequenz.channels import Broadcast
293+
294+
timer = Timer.timeout(timedelta(seconds=1.0), auto_start=False)
295+
chan = Broadcast[int]("input-chan")
296+
receiver1 = chan.new_receiver()
297+
290298
timer = Timer.timeout(timedelta(seconds=1.0), auto_start=False)
291299
# Do some other initialization, the timer will start automatically if
292300
# a message is awaited (or manually via `reset()`).
293301
select = Select(bat_1=receiver1, timer=timer)
294302
while await select.ready():
295303
if msg := select.bat_1:
296304
if val := msg.inner:
297-
process_data(val)
305+
battery_soc = val
298306
else:
299-
logging.warn("battery channel closed")
307+
logging.warning("battery channel closed")
300308
elif drift := select.timer:
301309
# Print some regular battery data
302-
print(f"Battery is charged at {battery.soc}%")
303-
if stop_logging:
304-
timer.stop()
305-
elif start_logging:
306-
timer.reset()
310+
print(f"Battery is charged at {battery_soc}%")
307311
```
308312
309313
Example: Timeout example
310314
```python
315+
import logging
316+
from frequenz.channels.util import Select
317+
from frequenz.channels import Broadcast
318+
319+
def process_data(data: int):
320+
logging.info("Processing data: %d", data)
321+
322+
def do_heavy_processing(data: int):
323+
logging.info("Heavy processing data: %d", data)
324+
311325
timer = Timer.timeout(timedelta(seconds=1.0), auto_start=False)
326+
chan1 = Broadcast[int]("input-chan-1")
327+
chan2 = Broadcast[int]("input-chan-2")
328+
receiver1 = chan1.new_receiver()
329+
receiver2 = chan2.new_receiver()
312330
select = Select(bat_1=receiver1, heavy_process=receiver2, timeout=timer)
313331
while await select.ready():
314332
if msg := select.bat_1:
315333
if val := msg.inner:
316334
process_data(val)
317335
timer.reset()
318336
else:
319-
logging.warn("battery channel closed")
337+
logging.warning("battery channel closed")
320338
if msg := select.heavy_process:
321339
if val := msg.inner:
322340
do_heavy_processing(val)
323341
else:
324-
logging.warn("processing channel closed")
342+
logging.warning("processing channel closed")
325343
elif drift := select.timeout:
326-
logging.warn("No data received in time")
344+
logging.warning("No data received in time")
327345
```
328346
329347
In this case `do_heavy_processing` might take 2 seconds, and we don't

0 commit comments

Comments
 (0)