Skip to content

Commit 8e1876b

Browse files
committed
Add support for linting code examples in docstrings
This commit adds a module with an utility function to be able to easily collect and lint code examples in docstrings. It also add an optional dependency to easily pull the dependencies needed to do this linting and ignores any optional dependency starting with `extra-` in the tests checking if new repo types were added, as optional dependencies starting with `extra-` are not really repository types. This is based on the work on the SDK: frequenz-floss/frequenz-sdk-python#384 Signed-off-by: Leandro Lucarella <[email protected]>
1 parent bd70e0e commit 8e1876b

File tree

7 files changed

+331
-2
lines changed

7 files changed

+331
-2
lines changed

RELEASE_NOTES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838

3939
## New Features
4040

41+
- Add support for linting code examples found in *docstrings*.
42+
43+
A new module `frequenz.repo.config.pytest.examples` is added with an utility function to be able to easily collect and lint code examples in *docstrings*.
44+
45+
There is also a new optional dependency `extra-lint-examples` to easily pull the dependencies needed to do this linting. Please have a look at the documentation in the `frequenz.repo.config` package for more details.
46+
4147
### Cookiecutter template
4248

4349
- Add a new GitHub workflow to check that release notes were updated.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ plugins:
109109
- https://nox.thea.codes/en/stable/objects.inv
110110
- https://oprypin.github.io/mkdocs-gen-files/objects.inv
111111
- https://setuptools.pypa.io/en/latest/objects.inv
112+
- https://sybil.readthedocs.io/en/stable/objects.inv
112113
- https://typing-extensions.readthedocs.io/en/stable/objects.inv
113114
- search
114115
- section-index

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ api = [
5656
app = []
5757
lib = []
5858
model = []
59+
extra-lint-examples = [
60+
"pylint >= 2.17.3, < 3",
61+
"pytest >= 7.3.0, < 8",
62+
"sybil >= 5.0.3, < 6",
63+
]
5964
dev-docstrings = [
6065
"pydocstyle == 6.3.0",
6166
"darglint == 1.8.1",
@@ -124,7 +129,7 @@ disable = [
124129
]
125130

126131
[[tool.mypy.overrides]]
127-
module = ["cookiecutter", "cookiecutter.*"]
132+
module = ["cookiecutter", "cookiecutter.*", "sybil", "sybil.*"]
128133
ignore_missing_imports = true
129134

130135
[tool.pytest.ini_options]

src/frequenz/repo/config/__init__.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,77 @@
207207
- path/to/my/custom/script.py
208208
```
209209
210+
## `pytest` (running tests)
211+
212+
### Linting examples in the source code's *docstrings*
213+
214+
To make sure the examples included in your source code's *docstrings* are valid, you can
215+
use [`pytest`](https://pypi.org/project/pytest/) to automatically collect all the
216+
examples wrapped in triple backticks (````python`) within our docstrings and validate
217+
them using [`pylint`](https://pypi.org/project/pylint/).
218+
219+
To do so there is some setup that's needed:
220+
221+
1. Add a `conftest.py` file to the root directory containing your source code with the
222+
following contents:
223+
224+
```python
225+
from frequenz.repo.config.pytest import examples
226+
from sybil import Sybil
227+
228+
pytest_collect_file = Sybil(**examples.get_sybil_arguments()).pytest()
229+
```
230+
231+
Unfortunately, because of how Sybil works, the [`Sybil`][sybil.Sybil] class needs to
232+
be instantiated in the `conftest.py` file. To easily do this, the convenience
233+
function
234+
[`get_sybil_arguments()`][frequenz.repo.config.pytest.examples.get_sybil_arguments]
235+
is provided to get the arguments to pass to the `Sybil()` constructor to be able to
236+
collect and lint the examples.
237+
238+
2. Add the following configuration to your `pyproject.toml` file (see
239+
the [`nox` section](#pyprojecttoml-configuration) for details on how to configure
240+
dependencies to play nicely with `nox`):
241+
242+
```toml
243+
[project.optional-dependencies]
244+
# ...
245+
dev-pytest = [
246+
# ...
247+
"frequenz-repo-config[extra-lint-examples] == 0.5.0",
248+
]
249+
# ...
250+
[[tool.mypy.overrides]]
251+
module = [
252+
# ...
253+
"sybil",
254+
"sybil.*",
255+
]
256+
ignore_missing_imports = true
257+
# ...
258+
[tool.pytest.ini_options]
259+
testpaths = [
260+
# ...
261+
"src",
262+
]
263+
```
264+
265+
This will make sure that you have the appropriate dependencies installed to run the
266+
the tests linting and that `mypy` doesn't complain about the `sybil` module not being
267+
typed.
268+
269+
3. Exclude the `src/conftest.py` file from the distribution package, as it shouldn't be
270+
shipped with the code, it is only for delelopment purposes. To do so, add the
271+
following line to the `MANIFEST.in` file:
272+
273+
```
274+
# ...
275+
exclude src/conftest.py
276+
```
277+
278+
Now you should be able to run `nox -s pytest` (or `pytest` directly) and see the tests
279+
linting the examples in your code's *docstrings*.
280+
210281
# APIs
211282
212283
## Protobuf configuation
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Pytest utilities.
5+
6+
This package contains utilities for testing with [`pytest`](https://pypi.org/project/pytest/).
7+
8+
The following modules are available:
9+
10+
- [`examples`][frequenz.repo.config.pytest.examples]: Utilities to enable linting of
11+
code examples in docstrings.
12+
"""
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Utility to enable linting of code examples in docstrings.
5+
6+
Code examples are often wrapped in triple backticks (````python`) within our docstrings.
7+
This plugin extracts these code examples and validates them using pylint.
8+
9+
The main utility function is
10+
[`get_sybil_arguments()`][frequenz.repo.config.pytest.examples.get_sybil_arguments],
11+
which returns a dictionary that can be used to pass to the [`Sybil()`][sybil.Sybil]
12+
constructor.
13+
14+
You still need to create a `conftest.py` file in the root of your project's sources,
15+
typically `src/conftest.py`, with the following contents:
16+
17+
```python
18+
from frequenz.repo.config.pytest import examples
19+
from sybil import Sybil
20+
21+
pytest_collect_file = Sybil(**examples.get_sybil_arguments()).pytest()
22+
```
23+
"""
24+
25+
import ast
26+
import os
27+
import subprocess
28+
from pathlib import Path
29+
from typing import Any
30+
31+
from sybil import Example
32+
from sybil.evaluators.python import pad
33+
from sybil.parsers.abstract.lexers import textwrap
34+
from sybil.parsers.myst import CodeBlockParser
35+
36+
_PYLINT_DISABLE_COMMENT = (
37+
"# pylint: {}=unused-import,wildcard-import,unused-wildcard-import"
38+
)
39+
40+
_FORMAT_STRING = """
41+
# Generated auto-imports for code example
42+
{disable_pylint}
43+
{imports}
44+
{enable_pylint}
45+
46+
{code}"""
47+
48+
49+
def get_sybil_arguments() -> dict[str, Any]:
50+
"""Get the arguments to pass when instantiating the Sybil object to lint docs examples.
51+
52+
Returns:
53+
The arguments to pass when instantiating the Sybil object.
54+
"""
55+
return {
56+
"parsers": [_CustomPythonCodeBlockParser()],
57+
"patterns": ["*.py"],
58+
}
59+
60+
61+
def _get_import_statements(code: str) -> list[str]:
62+
"""Get all import statements from a given code string.
63+
64+
Args:
65+
code: The code to extract import statements from.
66+
67+
Returns:
68+
A list of import statements.
69+
"""
70+
tree = ast.parse(code)
71+
import_statements: list[str] = []
72+
73+
for node in ast.walk(tree):
74+
if isinstance(node, (ast.Import, ast.ImportFrom)):
75+
import_statement = ast.get_source_segment(code, node)
76+
assert import_statement is not None
77+
import_statements.append(import_statement)
78+
79+
return import_statements
80+
81+
82+
def _path_to_import_statement(path: Path) -> str:
83+
"""Convert a path to a Python file to an import statement.
84+
85+
Args:
86+
path: The path to convert.
87+
88+
Returns:
89+
The import statement.
90+
91+
Raises:
92+
ValueError: If the path does not point to a Python file.
93+
"""
94+
# Make the path relative to the present working directory
95+
if path.is_absolute():
96+
path = path.relative_to(Path.cwd())
97+
98+
# Check if the path is a Python file
99+
if path.suffix != ".py":
100+
raise ValueError("Path must point to a Python file (.py)")
101+
102+
# Remove 'src' prefix if present
103+
parts = path.parts
104+
if parts[0] == "src":
105+
parts = parts[1:]
106+
107+
# Remove the '.py' extension and join parts with '.'
108+
module_path = ".".join(parts)[:-3]
109+
110+
# Create the import statement
111+
import_statement = f"from {module_path} import *"
112+
return import_statement
113+
114+
115+
# We need to add the type ignore comment here because the Sybil library does not
116+
# have type annotations.
117+
class _CustomPythonCodeBlockParser(CodeBlockParser): # type: ignore[misc]
118+
"""Code block parser that validates extracted code examples using pylint.
119+
120+
This parser is a modified version of the default Python code block parser
121+
from the Sybil library.
122+
It uses pylint to validate the extracted code examples.
123+
124+
All code examples are preceded by the original file's import statements as
125+
well as an wildcard import of the file itself.
126+
This allows us to use the code examples as if they were part of the original
127+
file.
128+
129+
Additionally, the code example is padded with empty lines to make sure the
130+
line numbers are correct.
131+
132+
Pylint warnings which are unimportant for code examples are disabled.
133+
"""
134+
135+
def __init__(self) -> None:
136+
"""Initialize the parser."""
137+
super().__init__("python")
138+
139+
def evaluate(self, example: Example) -> None | str:
140+
"""Validate the extracted code example using pylint.
141+
142+
Args:
143+
example: The extracted code example.
144+
145+
Returns:
146+
None if the code example is valid, otherwise the pylint output.
147+
"""
148+
# Get the import statements for the original file
149+
import_header = _get_import_statements(example.document.text)
150+
# Add a wildcard import of the original file
151+
import_header.append(
152+
_path_to_import_statement(Path(os.path.relpath(example.path)))
153+
)
154+
imports_code = "\n".join(import_header)
155+
156+
# Dedent the code example
157+
# There is also example.parsed that is already prepared, but it has
158+
# empty lines stripped and thus fucks up the line numbers.
159+
example_code = textwrap.dedent(
160+
example.document.text[example.start : example.end]
161+
)
162+
# Remove first line (the line with the triple backticks)
163+
example_code = example_code[example_code.find("\n") + 1 :]
164+
165+
example_with_imports = _FORMAT_STRING.format(
166+
disable_pylint=_PYLINT_DISABLE_COMMENT.format("disable"),
167+
imports=imports_code,
168+
enable_pylint=_PYLINT_DISABLE_COMMENT.format("enable"),
169+
code=example_code,
170+
)
171+
172+
# Make sure the line numbers are correct
173+
source = pad(
174+
example_with_imports,
175+
example.line - imports_code.count("\n") - _FORMAT_STRING.count("\n"),
176+
)
177+
178+
# pylint disable parameters
179+
pylint_disable_params = [
180+
"missing-module-docstring",
181+
"missing-class-docstring",
182+
"missing-function-docstring",
183+
"reimported",
184+
"unused-variable",
185+
"no-name-in-module",
186+
"await-outside-async",
187+
]
188+
189+
response = _validate_with_pylint(source, example.path, pylint_disable_params)
190+
191+
if len(response) > 0:
192+
return (
193+
f"Pylint validation failed for code example:\n"
194+
f"{example_with_imports}\nOutput: " + "\n".join(response)
195+
)
196+
197+
return None
198+
199+
200+
def _validate_with_pylint(
201+
code_example: str, path: str, disable_params: list[str]
202+
) -> list[str]:
203+
"""Validate a code example using pylint.
204+
205+
Args:
206+
code_example: The code example to validate.
207+
path: The path to the original file.
208+
disable_params: The pylint disable parameters.
209+
210+
Returns:
211+
A list of pylint messages.
212+
"""
213+
try:
214+
pylint_command = [
215+
"pylint",
216+
"--disable",
217+
",".join(disable_params),
218+
"--from-stdin",
219+
path,
220+
]
221+
222+
subprocess.run(
223+
pylint_command,
224+
input=code_example,
225+
text=True,
226+
capture_output=True,
227+
check=True,
228+
)
229+
except subprocess.CalledProcessError as exception:
230+
output = exception.output
231+
assert isinstance(output, str)
232+
return output.splitlines()
233+
234+
return []

tests/test_pyproject.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ def test_optional_dependencies() -> None:
1919
defined = {
2020
k
2121
for k in pyproject_toml["project"]["optional-dependencies"].keys()
22-
if k != "dev" and not k.startswith("dev-")
22+
if k != "dev" and not k.startswith("dev-") and not k.startswith("extra-")
2323
}
2424
assert defined == expected, utils.MSG_UNEXPECTED_REPO_TYPES

0 commit comments

Comments
 (0)