|
| 1 | +# License: MIT |
| 2 | +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH |
| 3 | + |
| 4 | +"""Pytest plugin to validate docstring code examples. |
| 5 | +
|
| 6 | +Code examples are often wrapped in triple backticks (```) within our docstrings. |
| 7 | +This plugin extracts these code examples and validates them using pylint. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 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 = [] |
| 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 | + 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 | +class CustomPythonCodeBlockParser(CodeBlockParser): |
| 89 | + """Code block parser that validates extracted code examples using pylint. |
| 90 | +
|
| 91 | + This parser is a modified version of the default Python code block parser |
| 92 | + from the Sybil library. |
| 93 | + It uses pylint to validate the extracted code examples. |
| 94 | +
|
| 95 | + All code examples are preceded by the original file's import statements as |
| 96 | + well as an wildcard import of the file itself. |
| 97 | + This allows us to use the code examples as if they were part of the original |
| 98 | + file. |
| 99 | +
|
| 100 | + Additionally, the code example is padded with empty lines to make sure the |
| 101 | + line numbers are correct. |
| 102 | +
|
| 103 | + Pylint warnings which are unimportant for code examples are disabled. |
| 104 | + """ |
| 105 | + |
| 106 | + def __init__(self): |
| 107 | + """Initialize the parser.""" |
| 108 | + super().__init__("python") |
| 109 | + |
| 110 | + def evaluate(self, example: Example) -> None | str: |
| 111 | + """Validate the extracted code example using pylint. |
| 112 | +
|
| 113 | + Args: |
| 114 | + example: The extracted code example. |
| 115 | +
|
| 116 | + Returns: |
| 117 | + None if the code example is valid, otherwise the pylint output. |
| 118 | + """ |
| 119 | + # Get the import statements for the original file |
| 120 | + import_header = get_import_statements(example.document.text) |
| 121 | + # Add a wildcard import of the original file |
| 122 | + import_header.append( |
| 123 | + path_to_import_statement(Path(os.path.relpath(example.path))) |
| 124 | + ) |
| 125 | + imports_code = "\n".join(import_header) |
| 126 | + |
| 127 | + # Dedent the code example |
| 128 | + # There is also example.parsed that is already prepared, but it has |
| 129 | + # empty lines stripped and thus fucks up the line numbers. |
| 130 | + example_code = textwrap.dedent( |
| 131 | + example.document.text[example.start : example.end] |
| 132 | + ) |
| 133 | + # Remove first line (the line with the triple backticks) |
| 134 | + example_code = example_code[example_code.find("\n") + 1 :] |
| 135 | + |
| 136 | + example_with_imports = FORMAT_STRING.format( |
| 137 | + disable_pylint=PYLINT_DISABLE_COMMENT.format("disable"), |
| 138 | + imports=imports_code, |
| 139 | + enable_pylint=PYLINT_DISABLE_COMMENT.format("enable"), |
| 140 | + code=example_code, |
| 141 | + ) |
| 142 | + |
| 143 | + # Make sure the line numbers are correct |
| 144 | + source = pad( |
| 145 | + example_with_imports, |
| 146 | + example.line - imports_code.count("\n") - FORMAT_STRING.count("\n"), |
| 147 | + ) |
| 148 | + |
| 149 | + # pylint disable parameters |
| 150 | + pylint_disable_params = [ |
| 151 | + "missing-module-docstring", |
| 152 | + "missing-class-docstring", |
| 153 | + "missing-function-docstring", |
| 154 | + "reimported", |
| 155 | + "unused-variable", |
| 156 | + "no-name-in-module", |
| 157 | + "await-outside-async", |
| 158 | + ] |
| 159 | + |
| 160 | + response = validate_with_pylint(source, example.path, pylint_disable_params) |
| 161 | + |
| 162 | + if len(response) > 0: |
| 163 | + response_concats = "\n".join(response) |
| 164 | + return ( |
| 165 | + f"Pylint validation failed for code example:\n" |
| 166 | + f"{example_with_imports}\nOutput: {response_concats}" |
| 167 | + ) |
| 168 | + |
| 169 | + return None |
| 170 | + |
| 171 | + |
| 172 | +def validate_with_pylint( |
| 173 | + code_example: str, path: str, disable_params: list[str] |
| 174 | +) -> list[str]: |
| 175 | + """Validate a code example using pylint. |
| 176 | +
|
| 177 | + Args: |
| 178 | + code_example: The code example to validate. |
| 179 | + path: The path to the original file. |
| 180 | + disable_params: The pylint disable parameters. |
| 181 | +
|
| 182 | + Returns: |
| 183 | + A list of pylint messages. |
| 184 | + """ |
| 185 | + try: |
| 186 | + pylint_command = [ |
| 187 | + "pylint", |
| 188 | + "--disable", |
| 189 | + ",".join(disable_params), |
| 190 | + "--from-stdin", |
| 191 | + path, |
| 192 | + ] |
| 193 | + |
| 194 | + subprocess.run( |
| 195 | + pylint_command, |
| 196 | + input=code_example, |
| 197 | + text=True, |
| 198 | + capture_output=True, |
| 199 | + check=True, |
| 200 | + ) |
| 201 | + except subprocess.CalledProcessError as exception: |
| 202 | + return exception.output.splitlines() |
| 203 | + |
| 204 | + return [] |
| 205 | + |
| 206 | + |
| 207 | +pytest_collect_file = Sybil( |
| 208 | + parsers=[CustomPythonCodeBlockParser()], |
| 209 | + patterns=["*.py"], |
| 210 | +).pytest() |
0 commit comments