Files
rio/tests/test_documentation.py
2025-09-23 21:56:26 +02:00

247 lines
7.8 KiB
Python

import enum
import re
import textwrap
import typing as t
import imy.docstrings
import pytest
import rio.docs
from tests.utils.ruff import ruff_check, ruff_format
def parametrize_with_name(
param_name: str,
docs: t.Iterable[
imy.docstrings.FunctionDocs
| imy.docstrings.ClassDocs
| imy.docstrings.AttributeDocs
| imy.docstrings.ParameterDocs
],
):
def decorator(func):
return pytest.mark.parametrize(
param_name,
docs,
ids=[doc.name for doc in docs],
)(func)
return decorator
def _create_tests() -> None:
for docs in rio.docs.get_toplevel_documented_objects().values():
test_cls = _create_tests_for(docs)
globals()[test_cls.__name__] = test_cls
def _create_tests_for(
docs: imy.docstrings.ClassDocs | imy.docstrings.FunctionDocs,
) -> type:
if isinstance(docs, imy.docstrings.FunctionDocs):
cls = _create_function_tests(docs)
else:
cls = _create_class_tests(docs)
cls.__name__ = _get_name_for_test_class(docs)
return cls
def _get_name_for_test_class(
docs: imy.docstrings.FunctionDocs | imy.docstrings.ClassDocs,
) -> str:
return "Test<" + docs.full_name.removeprefix("rio.").replace(".", "_") + ">"
CODE_BLOCK_PATTERN = re.compile(r"```(.*?)```", flags=re.DOTALL)
def _get_code_blocks(docstring: str | None) -> list[str]:
"""
Returns a list of all code blocks in the docstring of a component.
"""
# No docs?
if not docstring:
return []
docstring = textwrap.dedent(docstring)
# Find any contained code blocks
result: list[str] = []
for match in CODE_BLOCK_PATTERN.finditer(docstring):
block: str = match.group(1)
# Split into language and source
linebreak = block.find("\n")
assert linebreak != -1
language = block[:linebreak]
block = block[linebreak + 1 :]
# Make sure a language is specified
assert language, "The code block has no language specified"
if language in ("py", "python"):
result.append(block)
return result
def _create_tests_for_docstring(
docs: imy.docstrings.ClassDocs
| imy.docstrings.FunctionDocs
| imy.docstrings.PropertyDocs,
) -> type:
code_blocks = _get_code_blocks(docs.summary)
code_blocks += _get_code_blocks(docs.details)
code_block_ids = [f"code block {nr}" for nr, _ in enumerate(code_blocks, 1)]
class DocstringTests:
# __init__ methods don't need a summary or details
if docs.name != "__init__":
def test_summary(self) -> None:
assert docs.summary is not None, f"{docs.name} has no summary"
def test_details(self) -> None:
assert docs.details is not None, f"{docs.name} has no details"
@pytest.mark.parametrize("code", code_blocks, ids=code_block_ids)
def test_code_block_is_formatted(self, code: str) -> None:
# Make sure all code blocks are formatted according to ruff
formatted_code = ruff_format(code)
# Ruff often inserts 2 empty lines between definitions, but that's
# really not necessary in docstrings. Collapse them to a single
# empty line.
code = code.replace("\n\n\n", "\n\n")
formatted_code = formatted_code.replace("\n\n\n", "\n\n")
assert formatted_code == code
@pytest.mark.parametrize("code", code_blocks, ids=code_block_ids)
def test_analyze_code_block(self, code: str) -> None:
# A lot of snippets are missing context, so it's only natural that
# ruff will find issues with the code. There isn't really anything
# we can do about it, so we'll just skip those objects.
if docs.object in (
rio.Color,
rio.UserSettings,
):
pytest.xfail()
errors = ruff_check(code)
assert not errors, errors
return DocstringTests
def _create_function_tests(docs: imy.docstrings.FunctionDocs) -> type:
# If the function is a decorator, there's no need to document that it takes
# a function/class as an argument
if docs.metadata.decorator and list(docs.parameters) == ["handler"]:
parameters = []
elif docs.has_implicit_first_parameter:
parameters = list(docs.parameters.values())[1:]
else:
parameters = docs.parameters.values()
DocstringTests = _create_tests_for_docstring(docs)
class FunctionTests(DocstringTests):
def test_parameters_are_all_public(self) -> None:
private_params = [
param.name for param in parameters if param.name.startswith("_")
]
assert not private_params, (
f"These parameters should be private: {private_params}"
)
def test_parameter_descriptions(self) -> None:
params_without_description = [
param.name for param in parameters if not param.description
]
assert not params_without_description, (
f"These parameters have no description: {params_without_description}"
)
return FunctionTests
def _create_property_tests(docs: imy.docstrings.PropertyDocs) -> type:
DocstringTests = _create_tests_for_docstring(docs)
class PropertyTests(DocstringTests):
pass
return PropertyTests
def _create_class_tests(docs: imy.docstrings.ClassDocs) -> type:
# Components only need their constructor documented, attributes don't matter
attributes = (
[]
if issubclass(docs.object, rio.Component)
else docs.attributes.values()
)
DocstringTests = _create_tests_for_docstring(docs)
class ClassTests(DocstringTests):
# Event and Error classes shouldn't be instantiated by the user, so make
# sure their constructor is marked as private
if docs.name.endswith("Event") or issubclass(
docs.object, BaseException
):
def test_constructor_is_private(self):
assert "__init__" not in docs.members, (
f"Constructor of {docs.name} is not marked as private"
)
def test_attributes_are_all_public(self):
private_attrs = [
attr.name
for attr in docs.attributes.values()
if attr.name.startswith("_")
]
assert not private_attrs, (
f"These attributes should be private: {private_attrs}"
)
@parametrize_with_name("attr", attributes)
def test_attribute_description(
self, attr: imy.docstrings.AttributeDocs
) -> None:
assert attr.description is not None, (
f"Attribute {attr.name!r} has no details"
)
# List, Set, and Dict methods don't need to be documented, since they're
# just clones of well-known classes.
if docs.object not in (rio.List, rio.Set, rio.Dict):
# Create tests for all members of this class
for member in docs.members.values():
if isinstance(member, imy.docstrings.FunctionDocs):
# Ignore the constructor of Enums
if member.name == "__init__" and issubclass(
docs.object, enum.Enum
):
continue
test = _create_function_tests(member)
elif isinstance(member, imy.docstrings.PropertyDocs):
test = _create_property_tests(member)
else:
raise Exception(
f"Don't know how to create tests for a {type(member).__name__} object"
)
test.__name__ = f"Test<{member.name}>"
locals()[test.__name__] = test
return ClassTests
_create_tests()