Source code for spinn_utilities.testing.docs_checker

# 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("")