Skip to content

Commit a853bdc

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 0200914 commit a853bdc

File tree

4 files changed

+10
-210
lines changed

4 files changed

+10
-210
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.84.2",
6969
"pytest-asyncio == 0.21.1",
70+
"frequenz-repo-config[extra-lint-examples] == 0.5.2",
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 & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -1,214 +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 frequenz.repo.config.pytest import examples
11+
from sybil import Sybil
1012

11-
import ast
12-
import os
13-
import subprocess
14-
from pathlib import Path
15-
16-
from sybil import Example, Sybil
17-
from sybil.evaluators.python import pad
18-
from sybil.parsers.abstract.lexers import textwrap
19-
from sybil.parsers.myst import CodeBlockParser
20-
21-
PYLINT_DISABLE_COMMENT = (
22-
"# pylint: {}=unused-import,wildcard-import,unused-wildcard-import"
23-
)
24-
25-
FORMAT_STRING = """
26-
# Generated auto-imports for code example
27-
{disable_pylint}
28-
{imports}
29-
{enable_pylint}
30-
31-
{code}"""
32-
33-
34-
def get_import_statements(code: str) -> list[str]:
35-
"""Get all import statements from a given code string.
36-
37-
Args:
38-
code: The code to extract import statements from.
39-
40-
Returns:
41-
A list of import statements.
42-
"""
43-
tree = ast.parse(code)
44-
import_statements: list[str] = []
45-
46-
for node in ast.walk(tree):
47-
if isinstance(node, (ast.Import, ast.ImportFrom)):
48-
import_statement = ast.get_source_segment(code, node)
49-
assert import_statement is not None
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-
# We need to add the type ignore comment here because the Sybil library does not
89-
# have type annotations.
90-
class CustomPythonCodeBlockParser(CodeBlockParser): # type: ignore[misc]
91-
"""Code block parser that validates extracted code examples using pylint.
92-
93-
This parser is a modified version of the default Python code block parser
94-
from the Sybil library.
95-
It uses pylint to validate the extracted code examples.
96-
97-
All code examples are preceded by the original file's import statements as
98-
well as an wildcard import of the file itself.
99-
This allows us to use the code examples as if they were part of the original
100-
file.
101-
102-
Additionally, the code example is padded with empty lines to make sure the
103-
line numbers are correct.
104-
105-
Pylint warnings which are unimportant for code examples are disabled.
106-
"""
107-
108-
def __init__(self) -> None:
109-
"""Initialize the parser."""
110-
super().__init__("python")
111-
112-
def evaluate(self, example: Example) -> None | str:
113-
"""Validate the extracted code example using pylint.
114-
115-
Args:
116-
example: The extracted code example.
117-
118-
Returns:
119-
None if the code example is valid, otherwise the pylint output.
120-
"""
121-
# Get the import statements for the original file
122-
import_header = get_import_statements(example.document.text)
123-
# Add a wildcard import of the original file
124-
import_header.append(
125-
path_to_import_statement(Path(os.path.relpath(example.path)))
126-
)
127-
imports_code = "\n".join(import_header)
128-
129-
# Dedent the code example
130-
# There is also example.parsed that is already prepared, but it has
131-
# empty lines stripped and thus fucks up the line numbers.
132-
example_code = textwrap.dedent(
133-
example.document.text[example.start : example.end]
134-
)
135-
# Remove first line (the line with the triple backticks)
136-
example_code = example_code[example_code.find("\n") + 1 :]
137-
138-
example_with_imports = FORMAT_STRING.format(
139-
disable_pylint=PYLINT_DISABLE_COMMENT.format("disable"),
140-
imports=imports_code,
141-
enable_pylint=PYLINT_DISABLE_COMMENT.format("enable"),
142-
code=example_code,
143-
)
144-
145-
# Make sure the line numbers are correct
146-
source = pad(
147-
example_with_imports,
148-
example.line - imports_code.count("\n") - FORMAT_STRING.count("\n"),
149-
)
150-
151-
# pylint disable parameters
152-
pylint_disable_params = [
153-
"missing-module-docstring",
154-
"missing-class-docstring",
155-
"missing-function-docstring",
156-
"reimported",
157-
"unused-variable",
158-
"no-name-in-module",
159-
"await-outside-async",
160-
]
161-
162-
response = validate_with_pylint(source, example.path, pylint_disable_params)
163-
164-
if len(response) > 0:
165-
response_concats = "\n".join(response)
166-
return (
167-
f"Pylint validation failed for code example:\n"
168-
f"{example_with_imports}\nOutput: {response_concats}"
169-
)
170-
171-
return None
172-
173-
174-
def validate_with_pylint(
175-
code_example: str, path: str, disable_params: list[str]
176-
) -> list[str]:
177-
"""Validate a code example using pylint.
178-
179-
Args:
180-
code_example: The code example to validate.
181-
path: The path to the original file.
182-
disable_params: The pylint disable parameters.
183-
184-
Returns:
185-
A list of pylint messages.
186-
"""
187-
try:
188-
pylint_command = [
189-
"pylint",
190-
"--disable",
191-
",".join(disable_params),
192-
"--from-stdin",
193-
path,
194-
]
195-
196-
subprocess.run(
197-
pylint_command,
198-
input=code_example,
199-
text=True,
200-
capture_output=True,
201-
check=True,
202-
)
203-
except subprocess.CalledProcessError as exception:
204-
output = exception.output
205-
assert isinstance(output, str)
206-
return output.splitlines()
207-
208-
return []
209-
210-
211-
pytest_collect_file = Sybil(
212-
parsers=[CustomPythonCodeBlockParser()],
213-
patterns=["*.py"],
214-
).pytest()
13+
pytest_collect_file = Sybil(**examples.get_sybil_arguments()).pytest()

0 commit comments

Comments
 (0)