# Copyright (c) 2025 The University of Manchester
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ast
import os
import sys
from typing import cast, Set
import docstring_parser
ERROR_NONE = 0
ERROR_OTHER = ERROR_NONE + 1
ERROR_FILE = ERROR_OTHER + 1
[docs]
class DocsChecker(object):
"""
A utility class to check the docs strings for our rules.
As we use type annotations doc strings should not have types.
At best they are the same otherwise they are wrong.
We check that all documented params are actually ones used.
The inverse is not checked as a good param name is often enough
"""
__slots__ = [
"__check_init", "__check_short", "__check_params",
"__check_properties", "__check_returns",
"__check_types_in_docs",
"__error_level", "__file_path", "__file_errors",
"__functions_errors"]
def __init__(
self, *, check_init: bool = True, check_short: bool = True,
check_params: bool = True, check_properties: bool = True,
check_returns: bool = True,
check_types_in_docs: bool = True) -> None:
"""
Sets up the doc checker.
Which functions need to be documented is left to pylint to check.
Currently, that is public methods and public methods of public classes.
pylint does not insist init methods are documented.
:param check_init: flag to trigger checking of __init__ methods.
If True all init methods must have all params documented
Descriptions not allowed on __init__ files
as they should be on the class only.
:param check_short: Flag to trigger checking of a description.
For public (None init) methods (except setters) with no return
there must be a short description
:param check_params: Flag to trigger checking of params.
If any param is listed all must be.
The param does not need to be documented.
:param check_properties: Flag to trigger checking of Properties
They must include a description.
They should not have a return annotation
:param check_returns: Flag to trigger checking of return annotations
when not a property
:param check_types_in_docs:
Flag to trigger checking that doc-string have no types
"""
self.__error_level = ERROR_NONE
self.__check_types_in_docs = check_types_in_docs
self.__check_init = check_init
self.__check_params = check_params
self.__check_properties = check_properties
self.__check_short = check_short
self.__check_returns = check_returns
self.__file_path = "None"
self.__file_errors = 0
self.__functions_errors = 0
[docs]
def check_dir(self, dir_path: str) -> None:
"""
Checks all py files in this directory including subdirectories.
"""
for root, dir_names, files in os.walk(dir_path):
dir_names.sort()
files.sort()
for file_name in files:
if file_name.endswith(".py"):
self.check_file(os.path.join(root, file_name))
[docs]
def check_file(self, file_path: str) -> None:
"""
Check the documentation in this file.
"""
if self.__error_level > ERROR_OTHER:
self.__error_level = ERROR_OTHER
self.__file_path = file_path
with open(file_path, "r", encoding="utf-8") as file:
raw_tree = file.read()
try:
ast_tree = ast.parse(raw_tree, type_comments=True)
except SyntaxError as ex:
raise SyntaxError(f"{ex.msg} of {file_path}") from ex
for node in ast.walk(ast_tree):
if isinstance(node, ast.FunctionDef):
self.check_function(node)
def _check_function(self, node: ast.FunctionDef) -> str:
"""
Check the documentation in this function.
"""
if self.__error_level > ERROR_FILE:
self.__error_level = ERROR_FILE
_docs = ast.get_docstring(node)
if _docs is None:
# pylint does not require init to have docs
if node.name == "__init__" and self.__check_init:
param_names = self.get_param_names(node)
if (len(param_names) > 0 and self.is_not_overload(node) and
not self._test_path()):
return "missing docstring"
return ""
else:
docs = cast(str, _docs)
docstring = docstring_parser.parse(docs)
param_names = self.get_param_names(node)
error = self._check_params_correct(param_names, docstring)
# TODO remove when all repositories fixed
# if not self.has_returns(node) and len(docstring.many_returns) > 0:
# error += "Unexpected returns"
if (node.name.startswith("_") or self._test_path() or
self._overrides(node)):
# these are not included by readthedocs so less important
return error
if node.name == "__init__":
if self.__check_init:
if len(docstring.params) != len(param_names):
error += "Missing params"
if docstring.short_description is not None:
error += "Short description provided."
if len(docstring.many_returns) > 0:
error += ("Unexpected returns")
elif self.has_returns(node):
if self.is_property(node):
if self.__check_properties:
if docstring.short_description is None:
# docstring_parser can not handle these
# Read The Docs can
if not docs.startswith(":math:"):
error += "No short description provided."
if len(docstring.many_returns) > 0:
error += "Unexpected returns"
if self.__check_params:
error += self._check_params_all_or_none(
param_names, docstring)
else:
if self.__check_returns:
if len(docstring.many_returns) == 0:
error += "No returns"
if self.__check_params:
error += self._check_params_all_or_none(
param_names, docstring)
else:
if self.__check_short:
if docstring.short_description is None:
error += "No short description provided."
if self.__check_params:
error += self._check_params_all_or_none(
param_names, docstring)
error += self._check_blank_lines(docs)
return error
[docs]
def check_function(self, node: ast.FunctionDef) -> None:
"""
Check the documentation in this function.
"""
if self.__error_level > ERROR_FILE:
self.__error_level = ERROR_FILE
error = self._check_function(node)
if error:
if self.__error_level < ERROR_FILE:
print(f"{self.__file_path}")
self.__file_errors += 1
self.__error_level = ERROR_FILE
print(f"\t{node.name} {node.lineno}")
print(f"\t{error}")
self.__functions_errors += 1
[docs]
def is_property(self, node: ast.FunctionDef) -> bool:
"""
:return: True if and only if there is a @property decorator
"""
for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id == "property":
return True
return False
[docs]
def is_not_overload(self, node: ast.FunctionDef) -> bool:
"""
:return: True if and only if there is NO @overload decorator
"""
for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id == "overload":
return False
return True
[docs]
def has_returns(self, node: ast.FunctionDef) -> bool:
"""
:return: True if and only if there is a return declared
"""
returns = node.returns
if isinstance(returns, ast.Constant):
if returns.value is None:
return False
elif isinstance(returns, ast.Name):
if returns.id == "Never":
return False
return True
def _overrides(self, node: ast.FunctionDef) -> bool:
"""
Detects the overrides annotation and the extend_docs is not False
"""
for decorator in node.decorator_list:
try:
if (isinstance(decorator, ast.Call) and
isinstance(decorator.func, ast.Name) and
decorator.func.id == "overrides"):
for keyword in decorator.keywords:
if keyword.arg == "extend_doc":
value = cast(ast.Constant, keyword.value)
return bool(value.value)
return True
except AttributeError:
print(decorator)
return False
def _test_path(self) -> bool:
"""
:returns: True if the path is likely for tests
"""
test_paths = ["fec_integration_tests", "pacman_test_objects",
"spynnaker_integration_tests", "tests", "unittests"]
for test_path in test_paths:
check = os.sep + test_path + os.sep
if check in self.__file_path:
return True
return False
def _check_params_correct(
self, param_names: Set[str],
docstring: docstring_parser.common.Docstring) -> str:
"""
Checks that all params listed are used and not typed
"""
error = ""
for param in docstring.params:
if param.arg_name not in param_names:
error += f"{param.arg_name}: is incorrect "
elif param.type_name:
if self.__check_types_in_docs:
error += f"{param.arg_name}: is typed "
return error
def _check_params_all_or_none(
self, param_names: Set[str],
docstring: docstring_parser.common.Docstring) -> str:
"""
Checks that either no or all params are listed
"""
if len(docstring.params) == 0:
return ""
elif len(docstring.params) != len(param_names):
return "Missing params"
else:
return ""
def _check_blank_lines(self, docs: str) -> str:
"""
Check there are blank lines where needed
"""
error = ""
if self.__check_types_in_docs:
for key in [":type", ":rtype"]:
if key in docs:
error += f"found {key} "
index = sys.maxsize
for key in [":param", ":return", ":raises"]:
if key in docs:
key_index = docs.index(key)
if key_index < index:
index = key_index
if index < sys.maxsize:
while index > 1 and docs[index-1] in [" ", "\t"]:
index -= 1
if index >= 2:
if docs[index-2: index] != "\n\n":
error += "Missing blank line after description"
return error
[docs]
def get_param_names(self, node: ast.FunctionDef) -> Set[str]:
"""
Gets the names of the parameters found in the abstract syntax tree.
These are the ones actually declared.
:returns: Names of all parameter including normal and kwargs ones
"""
param_names: Set[str] = set()
for arg in node.args.args:
param_names.add(arg.arg)
for arg in node.args.kwonlyargs:
param_names.add(arg.arg)
if node.args.vararg:
param_names.add(node.args.vararg.arg)
if node.args.kwarg:
param_names.add(node.args.kwarg.arg)
if "self" in param_names:
param_names.remove("self")
if "cls" in param_names:
param_names.remove("cls")
return param_names
[docs]
def check_no_errors(self) -> None:
"""
Checks that there are no errors found.
Does not run any checks just check status after they are run
:raises AssertionError: If any previous check found an error
"""
if self.__error_level > ERROR_NONE:
raise AssertionError(
f"The documentation checker found "
f"{self.__functions_errors} errors "
f"in {self.__file_errors} files")
if __name__ == "__main__":
checker = DocsChecker(
check_init=False,
check_short=False,
check_params=False,
check_properties=False
)
# checker.check_dir("")
checker.check_file("")