Skip to content

Commit 9672e9c

Browse files
committed
Use repo-config for linting examples in docstrings
Now repo-config ships support to easily enable the collection and linting of code examples in docstrings, so we use that instead. Also make sure that we label the `conftest.py` file properly and we exclude it from the source distribution. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 142e270 commit 9672e9c

File tree

4 files changed

+10
-211
lines changed

4 files changed

+10
-211
lines changed

.github/labeler.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
- LICENSE
1414

1515
"part:tests":
16+
- "**/conftest.py"
1617
- "tests/**"
1718

1819
"part:tooling":
1920
- "**/*.ini"
2021
- "**/*.toml"
2122
- "**/*.yaml"
2223
- "**/*.yml"
24+
- "**/conftest.py"
2325
- ".editorconfig"
2426
- ".git*"
2527
- ".git*/**"

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ exclude .darglint
33
exclude .editorconfig
44
exclude noxfile.py
55
exclude CODEOWNERS
6+
exclude src/conftest.py
67
recursive-exclude .github *
78
recursive-exclude tests *
89
recursive-exclude benchmarks *

pyproject.toml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,8 @@ dev-pytest = [
6767
"async-solipsism == 0.5",
6868
"hypothesis == 6.82.4",
6969
"pytest-asyncio == 0.21.1",
70+
"frequenz-repo-config[extra-lint-examples] == 0.5.1",
7071
"pytest-mock == 3.11.1",
71-
# For checking docs examples
72-
"sybil == 5.0.3",
73-
"pylint == 2.17.5",
7472
]
7573
dev = [
7674
"frequenz-channels[dev-mkdocs,dev-docstrings,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]",
@@ -110,7 +108,7 @@ disable = [
110108
]
111109

112110
[tool.pytest.ini_options]
113-
testpaths = ["tests", "src"] # src for docs examples
111+
testpaths = ["tests", "src"]
114112
asyncio_mode = "auto"
115113
required_plugins = ["pytest-asyncio", "pytest-mock"]
116114
markers = [

src/conftest.py

Lines changed: 5 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -1,215 +1,13 @@
11
# License: MIT
22
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
33

4-
"""Pytest plugin to validate docstring code examples.
4+
"""Validate docstring code examples.
55
6-
Code examples are often wrapped in triple backticks (```) within our docstrings.
6+
Code examples are often wrapped in triple backticks (```) within docstrings.
77
This plugin extracts these code examples and validates them using pylint.
88
"""
99

10-
from __future__ import annotations
10+
from frequenz.repo.config.pytest import examples
11+
from sybil import Sybil
1112

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: list[str] = []
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-
assert import_statement is not None
51-
import_statements.append(import_statement)
52-
53-
return import_statements
54-
55-
56-
def path_to_import_statement(path: Path) -> str:
57-
"""Convert a path to a Python file to an import statement.
58-
59-
Args:
60-
path: The path to convert.
61-
62-
Returns:
63-
The import statement.
64-
65-
Raises:
66-
ValueError: If the path does not point to a Python file.
67-
"""
68-
# Make the path relative to the present working directory
69-
if path.is_absolute():
70-
path = path.relative_to(Path.cwd())
71-
72-
# Check if the path is a Python file
73-
if path.suffix != ".py":
74-
raise ValueError("Path must point to a Python file (.py)")
75-
76-
# Remove 'src' prefix if present
77-
parts = path.parts
78-
if parts[0] == "src":
79-
parts = parts[1:]
80-
81-
# Remove the '.py' extension and join parts with '.'
82-
module_path = ".".join(parts)[:-3]
83-
84-
# Create the import statement
85-
import_statement = f"from {module_path} import *"
86-
return import_statement
87-
88-
89-
# We need to add the type ignore comment here because the Sybil library does not
90-
# have type annotations.
91-
class CustomPythonCodeBlockParser(CodeBlockParser): # type: ignore[misc]
92-
"""Code block parser that validates extracted code examples using pylint.
93-
94-
This parser is a modified version of the default Python code block parser
95-
from the Sybil library.
96-
It uses pylint to validate the extracted code examples.
97-
98-
All code examples are preceded by the original file's import statements as
99-
well as an wildcard import of the file itself.
100-
This allows us to use the code examples as if they were part of the original
101-
file.
102-
103-
Additionally, the code example is padded with empty lines to make sure the
104-
line numbers are correct.
105-
106-
Pylint warnings which are unimportant for code examples are disabled.
107-
"""
108-
109-
def __init__(self) -> None:
110-
"""Initialize the parser."""
111-
super().__init__("python")
112-
113-
def evaluate(self, example: Example) -> None | str:
114-
"""Validate the extracted code example using pylint.
115-
116-
Args:
117-
example: The extracted code example.
118-
119-
Returns:
120-
None if the code example is valid, otherwise the pylint output.
121-
"""
122-
# Get the import statements for the original file
123-
import_header = get_import_statements(example.document.text)
124-
# Add a wildcard import of the original file
125-
import_header.append(
126-
path_to_import_statement(Path(os.path.relpath(example.path)))
127-
)
128-
imports_code = "\n".join(import_header)
129-
130-
# Dedent the code example
131-
# There is also example.parsed that is already prepared, but it has
132-
# empty lines stripped and thus fucks up the line numbers.
133-
example_code = textwrap.dedent(
134-
example.document.text[example.start : example.end]
135-
)
136-
# Remove first line (the line with the triple backticks)
137-
example_code = example_code[example_code.find("\n") + 1 :]
138-
139-
example_with_imports = FORMAT_STRING.format(
140-
disable_pylint=PYLINT_DISABLE_COMMENT.format("disable"),
141-
imports=imports_code,
142-
enable_pylint=PYLINT_DISABLE_COMMENT.format("enable"),
143-
code=example_code,
144-
)
145-
146-
# Make sure the line numbers are correct
147-
source = pad(
148-
example_with_imports,
149-
example.line - imports_code.count("\n") - FORMAT_STRING.count("\n"),
150-
)
151-
152-
# pylint disable parameters
153-
pylint_disable_params = [
154-
"missing-module-docstring",
155-
"missing-class-docstring",
156-
"missing-function-docstring",
157-
"reimported",
158-
"unused-variable",
159-
"no-name-in-module",
160-
"await-outside-async",
161-
]
162-
163-
response = validate_with_pylint(source, example.path, pylint_disable_params)
164-
165-
if len(response) > 0:
166-
response_concats = "\n".join(response)
167-
return (
168-
f"Pylint validation failed for code example:\n"
169-
f"{example_with_imports}\nOutput: {response_concats}"
170-
)
171-
172-
return None
173-
174-
175-
def validate_with_pylint(
176-
code_example: str, path: str, disable_params: list[str]
177-
) -> list[str]:
178-
"""Validate a code example using pylint.
179-
180-
Args:
181-
code_example: The code example to validate.
182-
path: The path to the original file.
183-
disable_params: The pylint disable parameters.
184-
185-
Returns:
186-
A list of pylint messages.
187-
"""
188-
try:
189-
pylint_command = [
190-
"pylint",
191-
"--disable",
192-
",".join(disable_params),
193-
"--from-stdin",
194-
path,
195-
]
196-
197-
subprocess.run(
198-
pylint_command,
199-
input=code_example,
200-
text=True,
201-
capture_output=True,
202-
check=True,
203-
)
204-
except subprocess.CalledProcessError as exception:
205-
output = exception.output
206-
assert isinstance(output, str)
207-
return output.splitlines()
208-
209-
return []
210-
211-
212-
pytest_collect_file = Sybil(
213-
parsers=[CustomPythonCodeBlockParser()],
214-
patterns=["*.py"],
215-
).pytest()
13+
pytest_collect_file = Sybil(**examples.get_sybil_arguments()).pytest()

0 commit comments

Comments
 (0)