chore: add script to check docstrings
This commit is contained in:
174
scripts/docstring_checker.py
Normal file
174
scripts/docstring_checker.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import ast
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArgDoc:
|
||||
name: str
|
||||
type: str
|
||||
optional: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Param:
|
||||
name: str
|
||||
annotation: Optional[str]
|
||||
optional: bool
|
||||
|
||||
|
||||
class Checker(ast.NodeVisitor):
|
||||
def _get_args(self, docstring: str) -> list[ArgDoc]:
|
||||
args: list[ArgDoc] = []
|
||||
|
||||
in_args: bool = False
|
||||
for line in docstring.splitlines():
|
||||
if not in_args:
|
||||
if line == "Args:":
|
||||
in_args = True
|
||||
continue
|
||||
|
||||
# End of args
|
||||
if not line.startswith(" "):
|
||||
break
|
||||
|
||||
# Continuation line
|
||||
if line.startswith(" "):
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
m = re.match(r"(?P<name>\w+) \((?P<type>.*?)(?P<opt>, optional)?\):", line)
|
||||
if m is None:
|
||||
continue
|
||||
|
||||
args.append(
|
||||
ArgDoc(
|
||||
name=m.group("name"),
|
||||
type=m.group("type"),
|
||||
optional=m.group("opt") is not None,
|
||||
)
|
||||
)
|
||||
|
||||
return args
|
||||
|
||||
def log(self, node: ast.FunctionDef, msg: str):
|
||||
loc: str = f"{node.name} L{node.lineno}:{node.col_offset+1}"
|
||||
print(f" ({loc}) {msg}")
|
||||
|
||||
def _is_ignored(self, node: ast.FunctionDef) -> bool:
|
||||
name: str = node.name
|
||||
if name.startswith("visit_") or name.startswith("_visit_"):
|
||||
return True
|
||||
if name.startswith("parse_") or name.startswith("_parse_"):
|
||||
return True
|
||||
if name.startswith("_print"):
|
||||
return True
|
||||
if name.startswith("_write"):
|
||||
return True
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
return True
|
||||
if name == "accept":
|
||||
return True
|
||||
node.decorator_list
|
||||
match node:
|
||||
case ast.FunctionDef(
|
||||
decorator_list=[
|
||||
ast.Call(
|
||||
func=ast.Name(id="method"),
|
||||
),
|
||||
],
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
||||
docstring: Optional[str] = ast.get_docstring(node)
|
||||
func_name: str = node.name
|
||||
if docstring is None:
|
||||
if not self._is_ignored(node):
|
||||
self.log(node, f"Missing docstring for function {func_name}")
|
||||
return
|
||||
|
||||
args_doc: list[ArgDoc] = self._get_args(docstring)
|
||||
by_name: dict[str, ArgDoc] = {}
|
||||
for doc in args_doc:
|
||||
if doc.name in by_name:
|
||||
self.log(node, f"Multiple documentation lines for argument {doc.name}")
|
||||
by_name[doc.name] = doc
|
||||
|
||||
all_params: list[Param] = []
|
||||
|
||||
pos_args: list[ast.arg] = node.args.posonlyargs
|
||||
mixed_args: list[ast.arg] = node.args.args
|
||||
kw_args: list[ast.arg] = node.args.kwonlyargs
|
||||
|
||||
def add_param(arg: ast.arg, optional: bool):
|
||||
all_params.append(
|
||||
Param(
|
||||
name=arg.arg,
|
||||
annotation=(
|
||||
ast.unparse(arg.annotation)
|
||||
if arg.annotation is not None
|
||||
else None
|
||||
),
|
||||
optional=optional,
|
||||
)
|
||||
)
|
||||
|
||||
n_pos: int = len(pos_args) + len(mixed_args)
|
||||
for i, arg in enumerate(pos_args):
|
||||
j: int = n_pos - i - 1
|
||||
optional: bool = j < len(node.args.defaults)
|
||||
add_param(arg, optional)
|
||||
|
||||
for i, arg in enumerate(mixed_args):
|
||||
j: int = len(mixed_args) - i - 1
|
||||
optional: bool = j < len(node.args.defaults)
|
||||
add_param(arg, optional)
|
||||
|
||||
for arg, default in zip(kw_args, node.args.kw_defaults):
|
||||
optional: bool = default is not None
|
||||
add_param(arg, optional)
|
||||
|
||||
for param in all_params:
|
||||
doc: Optional[ArgDoc] = by_name.get(param.name, None)
|
||||
if doc is None:
|
||||
if param.name not in {"self", "cls"}:
|
||||
self.log(
|
||||
node, f"Missing documentation for parameter '{param.name}'"
|
||||
)
|
||||
continue
|
||||
|
||||
if doc.name != param.name:
|
||||
self.log(node, f"Documentation mismatch for '{param.name}': wrong name")
|
||||
|
||||
if doc.type != param.annotation:
|
||||
self.log(node, f"Documentation mismatch for '{param.name}': wrong type")
|
||||
|
||||
if doc.optional != param.optional:
|
||||
self.log(
|
||||
node,
|
||||
f"Documentation mismatch for '{param.name}': wrong optionality",
|
||||
)
|
||||
|
||||
|
||||
def check_file(path: Path):
|
||||
source: str = path.read_text()
|
||||
tree = ast.parse(source)
|
||||
checker = Checker()
|
||||
checker.visit(tree)
|
||||
|
||||
|
||||
def main():
|
||||
folder: Path = (Path(__file__).parent.parent / "midas").resolve()
|
||||
all_files = folder.rglob("*.py")
|
||||
for f in all_files:
|
||||
print(f.relative_to(folder))
|
||||
check_file(f)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user