Utility to enable linting of code examples in docstrings.
Code examples are often wrapped in triple backticks (```python
) within our docstrings.
This plugin extracts these code examples and validates them using pylint.
The main utility function is
get_sybil_arguments()
,
which returns a dictionary that can be used to pass to the Sybil()
constructor.
You still need to create a conftest.py
file in the root of your project's sources,
typically src/conftest.py
, with the following contents:
from frequenz.repo.config.pytest import examples
from sybil import Sybil
pytest_collect_file = Sybil(**examples.get_sybil_arguments()).pytest()
Classes
frequenz.repo.config.pytest.examples.MyPythonCodeBlockParser
Bases: PythonCodeBlockParser
Custom Python code block parser that uses the custom code block parser.
Source code in frequenz/repo/config/pytest/examples.py
| class MyPythonCodeBlockParser(PythonCodeBlockParser):
"""Custom Python code block parser that uses the custom code block parser."""
codeblock_parser_class: type[_CustomPythonCodeBlockParser] = (
_CustomPythonCodeBlockParser
)
|
frequenz.repo.config.pytest.examples._CustomPythonCodeBlockParser
Bases: CodeBlockParser
Code block parser that validates extracted code examples using pylint.
This parser is a modified version of the default Python code block parser
from the Sybil library.
It uses pylint to validate the extracted code examples.
All code examples are preceded by the original file's import statements as
well as an wildcard import of the file itself.
This allows us to use the code examples as if they were part of the original
file.
Additionally, the code example is padded with empty lines to make sure the
line numbers are correct.
Pylint warnings which are unimportant for code examples are disabled.
Source code in frequenz/repo/config/pytest/examples.py
| class _CustomPythonCodeBlockParser(CodeBlockParser):
"""Code block parser that validates extracted code examples using pylint.
This parser is a modified version of the default Python code block parser
from the Sybil library.
It uses pylint to validate the extracted code examples.
All code examples are preceded by the original file's import statements as
well as an wildcard import of the file itself.
This allows us to use the code examples as if they were part of the original
file.
Additionally, the code example is padded with empty lines to make sure the
line numbers are correct.
Pylint warnings which are unimportant for code examples are disabled.
"""
def __init__(self, language: str | None = None, _: Evaluator | None = None) -> None:
"""Initialize the parser."""
super().__init__(language, self.evaluate)
def evaluate(self, example: Example) -> None | str:
"""Validate the extracted code example using pylint.
Args:
example: The extracted code example.
Returns:
None if the code example is valid, otherwise the pylint output.
"""
# Get the import statements for the original file
import_header = _get_import_statements(example.document.text)
# Add a wildcard import of the original file
import_header.append(
_path_to_import_statement(Path(os.path.relpath(example.path)))
)
imports_code = "\n".join(import_header)
example_with_imports = _FORMAT_STRING.format(
disable_pylint=_PYLINT_DISABLE_COMMENT.format("disable"),
imports=imports_code,
enable_pylint=_PYLINT_DISABLE_COMMENT.format("enable"),
code=example.parsed,
)
# Make sure the line numbers are correct
source = pad(
example_with_imports,
example.line - imports_code.count("\n") - _FORMAT_STRING.count("\n"),
)
# pylint disable parameters
pylint_disable_params = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"reimported",
"unused-variable",
"no-name-in-module",
"await-outside-async",
]
response = _validate_with_pylint(source, example.path, pylint_disable_params)
if len(response) > 0:
return (
f"Pylint validation failed for code example:\n"
f"{example_with_imports}\nOutput: " + "\n".join(response)
)
return None
|
Functions
__init__
__init__(
language: str | None = None, _: Evaluator | None = None
) -> None
Initialize the parser.
Source code in frequenz/repo/config/pytest/examples.py
| def __init__(self, language: str | None = None, _: Evaluator | None = None) -> None:
"""Initialize the parser."""
super().__init__(language, self.evaluate)
|
evaluate
Validate the extracted code example using pylint.
PARAMETER |
DESCRIPTION |
example
|
The extracted code example.
TYPE:
Example
|
RETURNS |
DESCRIPTION |
None | str
|
None if the code example is valid, otherwise the pylint output.
|
Source code in frequenz/repo/config/pytest/examples.py
| def evaluate(self, example: Example) -> None | str:
"""Validate the extracted code example using pylint.
Args:
example: The extracted code example.
Returns:
None if the code example is valid, otherwise the pylint output.
"""
# Get the import statements for the original file
import_header = _get_import_statements(example.document.text)
# Add a wildcard import of the original file
import_header.append(
_path_to_import_statement(Path(os.path.relpath(example.path)))
)
imports_code = "\n".join(import_header)
example_with_imports = _FORMAT_STRING.format(
disable_pylint=_PYLINT_DISABLE_COMMENT.format("disable"),
imports=imports_code,
enable_pylint=_PYLINT_DISABLE_COMMENT.format("enable"),
code=example.parsed,
)
# Make sure the line numbers are correct
source = pad(
example_with_imports,
example.line - imports_code.count("\n") - _FORMAT_STRING.count("\n"),
)
# pylint disable parameters
pylint_disable_params = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"reimported",
"unused-variable",
"no-name-in-module",
"await-outside-async",
]
response = _validate_with_pylint(source, example.path, pylint_disable_params)
if len(response) > 0:
return (
f"Pylint validation failed for code example:\n"
f"{example_with_imports}\nOutput: " + "\n".join(response)
)
return None
|
Functions
frequenz.repo.config.pytest.examples._get_import_statements
Get all import statements from a given code string.
PARAMETER |
DESCRIPTION |
code
|
The code to extract import statements from.
TYPE:
str
|
RETURNS |
DESCRIPTION |
list[str]
|
A list of import statements.
|
Source code in frequenz/repo/config/pytest/examples.py
| def _get_import_statements(code: str) -> list[str]:
"""Get all import statements from a given code string.
Args:
code: The code to extract import statements from.
Returns:
A list of import statements.
"""
tree = ast.parse(code)
import_statements: list[str] = []
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
import_statement = ast.get_source_segment(code, node)
assert import_statement is not None
import_statements.append(import_statement)
return import_statements
|
frequenz.repo.config.pytest.examples._path_to_import_statement
_path_to_import_statement(path: Path) -> str
Convert a path to a Python file to an import statement.
PARAMETER |
DESCRIPTION |
path
|
TYPE:
Path
|
RAISES |
DESCRIPTION |
ValueError
|
If the path does not point to a Python file.
|
Source code in frequenz/repo/config/pytest/examples.py
| def _path_to_import_statement(path: Path) -> str:
"""Convert a path to a Python file to an import statement.
Args:
path: The path to convert.
Returns:
The import statement.
Raises:
ValueError: If the path does not point to a Python file.
"""
# Make the path relative to the present working directory
if path.is_absolute():
path = path.relative_to(Path.cwd())
# Check if the path is a Python file
if path.suffix != ".py":
raise ValueError("Path must point to a Python file (.py)")
# Remove 'src' prefix if present
parts = path.parts
if parts[0] == "src":
parts = parts[1:]
# Remove the '.py' extension and join parts with '.'
module_path = ".".join(parts)[:-3]
# Create the import statement
import_statement = f"from {module_path} import *"
return import_statement
|
frequenz.repo.config.pytest.examples._validate_with_pylint
Validate a code example using pylint.
PARAMETER |
DESCRIPTION |
code_example
|
The code example to validate.
TYPE:
str
|
path
|
The path to the original file.
TYPE:
str
|
disable_params
|
The pylint disable parameters.
TYPE:
list[str]
|
RETURNS |
DESCRIPTION |
list[str]
|
A list of pylint messages.
|
Source code in frequenz/repo/config/pytest/examples.py
| def _validate_with_pylint(
code_example: str, path: str, disable_params: list[str]
) -> list[str]:
"""Validate a code example using pylint.
Args:
code_example: The code example to validate.
path: The path to the original file.
disable_params: The pylint disable parameters.
Returns:
A list of pylint messages.
"""
try:
pylint_command = [
"pylint",
"--disable",
",".join(disable_params),
"--from-stdin",
path,
]
subprocess.run(
pylint_command,
input=code_example,
text=True,
capture_output=True,
check=True,
)
except subprocess.CalledProcessError as exception:
output = exception.output
assert isinstance(output, str)
return output.splitlines()
return []
|
frequenz.repo.config.pytest.examples.get_sybil_arguments
Get the arguments to pass when instantiating the Sybil object to lint docs examples.
RETURNS |
DESCRIPTION |
dict[str, Any]
|
The arguments to pass when instantiating the Sybil object.
|
Source code in frequenz/repo/config/pytest/examples.py
| def get_sybil_arguments() -> dict[str, Any]:
"""Get the arguments to pass when instantiating the Sybil object to lint docs examples.
Returns:
The arguments to pass when instantiating the Sybil object.
"""
return {
"parsers": [MyPythonCodeBlockParser()],
"patterns": ["*.py"],
}
|