|
1 | 1 | # License: MIT |
2 | 2 | # Copyright © 2023 Frequenz Energy-as-a-Service GmbH |
3 | 3 |
|
4 | | -"""Pytest plugin to validate docstring code examples. |
| 4 | +"""Validate docstring code examples. |
5 | 5 |
|
6 | | -Code examples are often wrapped in triple backticks (```) within our docstrings. |
| 6 | +Code examples are often wrapped in triple backticks (```) within docstrings. |
7 | 7 | This plugin extracts these code examples and validates them using pylint. |
8 | 8 | """ |
9 | 9 |
|
10 | | -from __future__ import annotations |
| 10 | +from frequenz.repo.config.pytest import examples |
| 11 | +from sybil import Sybil |
11 | 12 |
|
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