76 lines
2.6 KiB
Python
76 lines
2.6 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Require docstrings on public production APIs."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import ast
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
def _needs_docstring(node: ast.AST, *, parent_class: str | None = None) -> bool:
|
||
|
|
"""Return whether `node` should carry an API contract docstring."""
|
||
|
|
|
||
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
|
|
name = node.name
|
||
|
|
if name.startswith("_") and name != "__init__":
|
||
|
|
return False
|
||
|
|
return not (parent_class and name.startswith("_"))
|
||
|
|
if isinstance(node, ast.ClassDef):
|
||
|
|
if node.name.startswith("_"):
|
||
|
|
return False
|
||
|
|
if any(
|
||
|
|
(isinstance(dec, ast.Name) and dec.id == "dataclass")
|
||
|
|
or (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name) and dec.func.id == "dataclass")
|
||
|
|
for dec in node.decorator_list
|
||
|
|
):
|
||
|
|
return False
|
||
|
|
if any(
|
||
|
|
isinstance(base, ast.Name) and base.id in {"Exception", "RuntimeError", "BaseException"}
|
||
|
|
for base in node.bases
|
||
|
|
):
|
||
|
|
return False
|
||
|
|
return not any(isinstance(base, ast.Name) and base.id == "BaseModel" for base in node.bases)
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def _iter_nodes(tree: ast.AST) -> list[tuple[ast.AST, str | None]]:
|
||
|
|
"""Yield top-level surface area nodes for contract checking."""
|
||
|
|
|
||
|
|
return [(node, None) for node in getattr(tree, "body", [])]
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
"""Scan the production package and fail on missing docstrings."""
|
||
|
|
|
||
|
|
parser = argparse.ArgumentParser()
|
||
|
|
parser.add_argument("--root", default="ariadne")
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
root = Path(args.root)
|
||
|
|
violations: list[str] = []
|
||
|
|
for path in sorted(root.rglob("*.py")):
|
||
|
|
if "__pycache__" in path.parts or ".venv" in path.parts:
|
||
|
|
continue
|
||
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||
|
|
for node, parent_class in _iter_nodes(tree):
|
||
|
|
if not _needs_docstring(node, parent_class=parent_class):
|
||
|
|
continue
|
||
|
|
if ast.get_docstring(node):
|
||
|
|
continue
|
||
|
|
if isinstance(node, ast.ClassDef):
|
||
|
|
violations.append(f"{path}: class {node.name} is missing a docstring")
|
||
|
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
|
|
owner = f"{parent_class}." if parent_class else ""
|
||
|
|
violations.append(f"{path}: {owner}{node.name} is missing a docstring")
|
||
|
|
|
||
|
|
if violations:
|
||
|
|
for item in violations:
|
||
|
|
print(item)
|
||
|
|
return 1
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|