Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate docstring examples #364

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ pytest = [
"pytest-asyncio == 0.21.0",
"time-machine == 2.9.0",
"async-solipsism == 0.5",
"sybil ~= 5.0.0",
leandro-lucarella-frequenz marked this conversation as resolved.
Show resolved Hide resolved
]
mypy = [
"mypy == 1.2.0",
Expand All @@ -86,6 +87,7 @@ pylint = [
"pylint == 2.17.3",
# For checking the noxfile, docs/ script, and tests
"frequenz-sdk[docs-gen,nox,pytest]",
"sybil ~= 5.0.0",
]
dev = [
"frequenz-sdk[docs-gen,docs-lint,format,nox,pytest,mypy,pylint]",
Expand Down
154 changes: 154 additions & 0 deletions src/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# License: MIT
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH

"""Pytest plugin to validate and run docstring code examples.

Code examples are often wrapped in triple backticks (```) within our docstrings.
This plugin extracts these code examples and validates them using pylint.
"""

from __future__ import annotations

import ast
import os
import subprocess

from sybil import Sybil
from sybil.evaluators.python import pad
from sybil.parsers.myst import CodeBlockParser


def get_import_statements(code: str) -> list[str]:
"""Get all import statements from a given code string.

Args:
code: The code to extract import statements from.

Returns:
A list of import statements.
"""
tree = ast.parse(code)
import_statements = []

for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
import_statement = ast.get_source_segment(code, node)
import_statements.append(import_statement)

return import_statements


def path_to_import_statement(path: str) -> str:
"""Convert a path to a Python file to an import statement.

Args:
path: The path to convert.

Returns:
The import statement.
"""
if not path.endswith(".py"):
raise ValueError("Path must point to a Python file (.py)")

# Remove 'src/' prefix if present
if path.startswith("src/"):
path = path[4:]

# Remove the '.py' extension and replace '/' with '.'
module_path = path[:-3].replace("/", ".")

# Create the import statement
import_statement = f"from {module_path} import *"
return import_statement
leandro-lucarella-frequenz marked this conversation as resolved.
Show resolved Hide resolved


class CustomPythonCodeBlockParser(CodeBlockParser):
"""Code block parser that validates extracted code examples using pylint.

This parser is a modified version of the default Python code block parser
from the Sybil library.
It uses pylint to validate the extracted code examples.

All code examples are preceded by the original file's import statements as
well as an wildcard import of the file itself.
This allows us to use the code examples as if they were part of the original
file.
leandro-lucarella-frequenz marked this conversation as resolved.
Show resolved Hide resolved

Additionally, the code example is padded with empty lines to make sure the
line numbers are correct.
leandro-lucarella-frequenz marked this conversation as resolved.
Show resolved Hide resolved

Pylint warnings which are unimportant for code examples are disabled.
leandro-lucarella-frequenz marked this conversation as resolved.
Show resolved Hide resolved
"""

def __init__(self):
super().__init__("python", None)

def evaluate(self, example) -> None | str:
"""Validate the extracted code example using pylint.

Args:
example: The extracted code example.

Returns:
None if the code example is valid, otherwise the pylint output.
"""
# Get the import statements for the original file
import_statements = get_import_statements(example.document.text)
# Add a wildcard import of the original file
import_statements.append(
path_to_import_statement(os.path.relpath(example.path))
)
imports_code = "\n".join(import_statements)

example_with_imports = f"{imports_code}\n\n{example.parsed}"

# Make sure the line numbers are correct (unfortunately, this is not
# exactly correct, but good enough to find the line in question)
source = pad(
example_with_imports,
example.line + example.parsed.line_offset - len(import_statements),
)

try:
# pylint disable parameters with descriptions
pylint_disable_params = [
"C0114", # Missing module docstring
"C0115", # Missing class docstring
"C0116", # Missing function or method docstring
"W0401", # Wildcard import
"W0404", # Reimport
"W0611", # Unused import
"W0612", # Unused variable
"W0614", # Unused import from wildcard
"E0611", # No name in module
"E1142", # Await used outside async function
leandro-lucarella-frequenz marked this conversation as resolved.
Show resolved Hide resolved
]

pylint_command = [
"pylint",
"--disable",
",".join(pylint_disable_params),
"--from-stdin",
example.path,
]

subprocess.run(
pylint_command,
input=source,
text=True,
capture_output=True,
check=True,
)
except subprocess.CalledProcessError as exception:
return (
f"Pylint validation failed for code example:\n"
f"{example_with_imports}\nError: {exception}\nOutput: {exception.output}"
)

return None


pytest_collect_file = Sybil(
parsers=[CustomPythonCodeBlockParser()],
patterns=["*.py"],
).pytest()
74 changes: 42 additions & 32 deletions src/frequenz/sdk/actor/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ def actor(cls: Type[Any]) -> Type[Any]:
TypeError: when the class doesn't have a `run` method as per spec.

Example (one actor receiving from two receivers):
``` python
```python
from frequenz.channels import Receiver, Sender, Broadcast
from frequenz.channels.util import Select
@actor
class EchoActor:
def __init__(
Expand All @@ -101,25 +103,30 @@ async def run(self) -> None:
await self._output.send(msg.inner)


input_chan_1: Broadcast[bool] = Broadcast("input_chan_1")
input_chan_2: Broadcast[bool] = Broadcast("input_chan_2")
async def main() -> None:
input_chan_1: Broadcast[bool] = Broadcast("input_chan_1")
input_chan_2: Broadcast[bool] = Broadcast("input_chan_2")

echo_chan: Broadcast[bool] = Broadcast("EchoChannel")
echo_chan: Broadcast[bool] = Broadcast("EchoChannel")

echo_actor = EchoActor(
"EchoActor",
recv1=input_chan_1.new_receiver(),
recv2=input_chan_2.new_receiver(),
output=echo_chan.new_sender(),
)
echo_rx = echo_chan.new_receiver()
echo_actor = EchoActor(
"EchoActor",
recv1=input_chan_1.new_receiver(),
recv2=input_chan_2.new_receiver(),
output=echo_chan.new_sender(),
)
echo_rx = echo_chan.new_receiver()

await input_chan_2.new_sender().send(True)
msg = await echo_rx.receive()

await input_chan_2.new_sender().send(True)
msg = await echo_rx.receive()
asyncio.run(main())
```

Example (two Actors composed):
``` python
```python
from frequenz.channels import Receiver, Sender, Broadcast
from frequenz.channels.util import Select
@actor
class Actor1:
def __init__(
Expand Down Expand Up @@ -154,24 +161,27 @@ async def run(self) -> None:
await self._output.send(msg)


input_chan: Broadcast[bool] = Broadcast("Input to A1")
a1_chan: Broadcast[bool] = Broadcast["A1 stream"]
a2_chan: Broadcast[bool] = Broadcast["A2 stream"]
a1 = Actor1(
name="ActorOne",
recv=input_chan.new_receiver(),
output=a1_chan.new_sender(),
)
a2 = Actor2(
name="ActorTwo",
recv=a1_chan.new_receiver(),
output=a2_chan.new_sender(),
)

a2_rx = a2_chan.new_receiver()

await input_chan.new_sender().send(True)
msg = await a2_rx.receive()
async def main() -> None:
input_chan: Broadcast[bool] = Broadcast("Input to A1")
a1_chan: Broadcast[bool] = Broadcast("A1 stream")
a2_chan: Broadcast[bool] = Broadcast("A2 stream")
a_1 = Actor1(
name="ActorOne",
recv=input_chan.new_receiver(),
output=a1_chan.new_sender(),
)
a_2 = Actor2(
name="ActorTwo",
recv=a1_chan.new_receiver(),
output=a2_chan.new_sender(),
)

a2_rx = a2_chan.new_receiver()

await input_chan.new_sender().send(True)
msg = await a2_rx.receive()

asyncio.run(main())
```

"""
Expand Down
84 changes: 52 additions & 32 deletions src/frequenz/sdk/actor/power_distributing/power_distributing.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ class PowerDistributingActor:
printed.

Example:
``` python
```python
import grpc.aio as grpcaio

from frequenz.sdk.microgrid.graph import _MicrogridComponentGraph
from frequenz.sdk.microgrid._graph import _MicrogridComponentGraph
from frequenz.sdk import microgrid
from frequenz.sdk.microgrid.component import ComponentCategory
from frequenz.sdk.actor import ResamplerConfig
from frequenz.sdk.actor.power_distributing import (
PowerDistributingActor,
Request,
Expand All @@ -103,42 +105,60 @@ class PowerDistributingActor:
PartialFailure,
Ignored,
)
from frequenz.channels import Bidirectional, Broadcast, Receiver, Sender
from datetime import timedelta
from frequenz.sdk import actor

HOST = "localhost"
PORT = 50051

async def main() -> None:
await microgrid.initialize(
HOST,
PORT,
ResamplerConfig(resampling_period=timedelta(seconds=1))
)

graph = microgrid.connection_manager.get().component_graph

batteries = graph.components(component_category={ComponentCategory.BATTERY})
batteries_ids = {c.component_id for c in batteries}

target = f"{host}:{port}"
grpc_channel = grpcaio.insecure_channel(target)
api = MicrogridGrpcClient(grpc_channel, target)
battery_status_channel = Broadcast[BatteryStatus]("battery-status")

graph = _MicrogridComponentGraph()
await graph.refresh_from_api(api)
channel = Bidirectional[Request, Result]("user1", "power_distributor")
power_distributor = PowerDistributingActor(
users_channels={"user1": channel.service_handle},
battery_status_sender=battery_status_channel.new_sender(),
)

batteries = graph.components(component_category={ComponentCategory.BATTERY})
batteries_ids = {c.component_id for c in batteries}
# Start the actor
await actor.run(power_distributor)

channel = Bidirectional[Request, Result]("user1", "power_distributor")
power_distributor = PowerDistributingActor(
mock_api, component_graph, {"user1": channel.service_handle}
)
client_handle = channel.client_handle

client_handle = channel.client_handle

# Set power 1200W to given batteries.
request = Request(power=1200.0, batteries=batteries_ids, request_timeout_sec=10.0)
await client_handle.send(request)

# It is recommended to use timeout when waiting for the response!
result: Result = await asyncio.wait_for(client_handle.receive(), timeout=10)

if isinstance(result, Success):
print("Command succeed")
elif isinstance(result, PartialFailure):
print(
f"Batteries {result.failed_batteries} failed, total failed power" \
f"{result.failed_power}")
elif isinstance(result, Ignored):
print(f"Request was ignored, because of newer request")
elif isinstance(result, Error):
print(f"Request failed with error: {result.msg}")
# Set power 1200W to given batteries.
request = Request(power=1200.0, batteries=batteries_ids, request_timeout_sec=10.0)
await client_handle.send(request)

# Set power 1200W to given batteries.
request = Request(power=1200, batteries=batteries_ids, request_timeout_sec=10.0)
await client_handle.send(request)

# It is recommended to use timeout when waiting for the response!
result: Result = await asyncio.wait_for(client_handle.receive(), timeout=10)

if isinstance(result, Success):
print("Command succeed")
elif isinstance(result, PartialFailure):
print(
f"Batteries {result.failed_batteries} failed, total failed power" \
f"{result.failed_power}"
)
elif isinstance(result, Ignored):
print("Request was ignored, because of newer request")
elif isinstance(result, Error):
print(f"Request failed with error: {result.msg}")
```
"""

Expand Down
Loading