Skip to content

Commit fe4727c

Browse files
authored
Validate docstring examples (#384)
- Add module to validate ``` python wrapped code examples in docstrings - Make all examples validate correctly fixes #81
2 parents 3edf6a5 + b25272d commit fe4727c

File tree

10 files changed

+358
-87
lines changed

10 files changed

+358
-87
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()

src/frequenz/sdk/actor/_decorator.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ def actor(cls: Type[Any]) -> Type[Any]:
7676
TypeError: when the class doesn't have a `run` method as per spec.
7777
7878
Example (one actor receiving from two receivers):
79-
``` python
79+
```python
80+
from frequenz.channels import Broadcast, Receiver, Sender
81+
from frequenz.channels.util import Select
8082
@actor
8183
class EchoActor:
8284
def __init__(
@@ -115,11 +117,12 @@ async def run(self) -> None:
115117
echo_rx = echo_chan.new_receiver()
116118
117119
await input_chan_2.new_sender().send(True)
118-
msg = await echo_rx.receive()
120+
received_msg = await echo_rx.receive()
119121
```
120122
121123
Example (two Actors composed):
122-
``` python
124+
```python
125+
from frequenz.channels import Broadcast, Receiver, Sender
123126
@actor
124127
class Actor1:
125128
def __init__(
@@ -153,16 +156,15 @@ async def run(self) -> None:
153156
async for msg in self._recv:
154157
await self._output.send(msg)
155158
156-
157159
input_chan: Broadcast[bool] = Broadcast("Input to A1")
158-
a1_chan: Broadcast[bool] = Broadcast["A1 stream"]
159-
a2_chan: Broadcast[bool] = Broadcast["A2 stream"]
160-
a1 = Actor1(
160+
a1_chan: Broadcast[bool] = Broadcast("A1 stream")
161+
a2_chan: Broadcast[bool] = Broadcast("A2 stream")
162+
a_1 = Actor1(
161163
name="ActorOne",
162164
recv=input_chan.new_receiver(),
163165
output=a1_chan.new_sender(),
164166
)
165-
a2 = Actor2(
167+
a_2 = Actor2(
166168
name="ActorTwo",
167169
recv=a1_chan.new_receiver(),
168170
output=a2_chan.new_sender(),
@@ -171,7 +173,7 @@ async def run(self) -> None:
171173
a2_rx = a2_chan.new_receiver()
172174
173175
await input_chan.new_sender().send(True)
174-
msg = await a2_rx.receive()
176+
received_msg = await a2_rx.receive()
175177
```
176178
177179
"""

src/frequenz/sdk/actor/power_distributing/power_distributing.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,10 @@ class PowerDistributingActor:
8989
printed.
9090
9191
Example:
92-
``` python
93-
import grpc.aio as grpcaio
94-
95-
from frequenz.sdk.microgrid.graph import _MicrogridComponentGraph
92+
```python
93+
from frequenz.sdk import microgrid
9694
from frequenz.sdk.microgrid.component import ComponentCategory
95+
from frequenz.sdk.actor import ResamplerConfig
9796
from frequenz.sdk.actor.power_distributing import (
9897
PowerDistributingActor,
9998
Request,
@@ -103,29 +102,45 @@ class PowerDistributingActor:
103102
PartialFailure,
104103
Ignored,
105104
)
105+
from frequenz.channels import Bidirectional, Broadcast, Receiver, Sender
106+
from datetime import timedelta
107+
from frequenz.sdk import actor
106108
109+
HOST = "localhost"
110+
PORT = 50051
107111
108-
target = f"{host}:{port}"
109-
grpc_channel = grpcaio.insecure_channel(target)
110-
api = MicrogridGrpcClient(grpc_channel, target)
112+
await microgrid.initialize(
113+
HOST,
114+
PORT,
115+
ResamplerConfig(resampling_period=timedelta(seconds=1))
116+
)
111117
112-
graph = _MicrogridComponentGraph()
113-
await graph.refresh_from_api(api)
118+
graph = microgrid.connection_manager.get().component_graph
114119
115120
batteries = graph.components(component_category={ComponentCategory.BATTERY})
116121
batteries_ids = {c.component_id for c in batteries}
117122
123+
battery_status_channel = Broadcast[BatteryStatus]("battery-status")
124+
118125
channel = Bidirectional[Request, Result]("user1", "power_distributor")
119126
power_distributor = PowerDistributingActor(
120-
mock_api, component_graph, {"user1": channel.service_handle}
127+
users_channels={"user1": channel.service_handle},
128+
battery_status_sender=battery_status_channel.new_sender(),
121129
)
122130
131+
# Start the actor
132+
await actor.run(power_distributor)
133+
123134
client_handle = channel.client_handle
124135
125136
# Set power 1200W to given batteries.
126137
request = Request(power=1200.0, batteries=batteries_ids, request_timeout_sec=10.0)
127138
await client_handle.send(request)
128139
140+
# Set power 1200W to given batteries.
141+
request = Request(power=1200, batteries=batteries_ids, request_timeout_sec=10.0)
142+
await client_handle.send(request)
143+
129144
# It is recommended to use timeout when waiting for the response!
130145
result: Result = await asyncio.wait_for(client_handle.receive(), timeout=10)
131146
@@ -134,9 +149,10 @@ class PowerDistributingActor:
134149
elif isinstance(result, PartialFailure):
135150
print(
136151
f"Batteries {result.failed_batteries} failed, total failed power" \
137-
f"{result.failed_power}")
152+
f"{result.failed_power}"
153+
)
138154
elif isinstance(result, Ignored):
139-
print(f"Request was ignored, because of newer request")
155+
print("Request was ignored, because of newer request")
140156
elif isinstance(result, Error):
141157
print(f"Request failed with error: {result.msg}")
142158
```

0 commit comments

Comments
 (0)