Last active
July 8, 2026 05:35
-
-
Save pszemraj/23952e8712c8651fd086e0be62698b23 to your computer and use it in GitHub Desktop.
catch missing or incomplete docstrings and typing in python scripts. enforce standards on LLM-generated code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Check Python files for missing docstrings, type hints, and lazy docstrings. | |
| A *lazy* docstring is one that exists but under-documents the function: it has | |
| 2+ parameters and not all of them appear in the docstring, or it takes at least | |
| one parameter (or is a generator) and returns/yields a value without a | |
| ``Returns:``/``Yields:`` section. Lazy checking runs by default; disable it with | |
| ``--no-check-lazy``. | |
| Optionally, ``--check-raises`` (off by default) flags functions whose body | |
| contains an explicit ``raise Exc(...)`` but whose docstring has no ``Raises:`` | |
| section. Only explicit raises in the function's own body are detected; bare | |
| re-raises and exceptions propagated from callees are not. | |
| Functions that are ``@overload`` stubs, ``@property`` setters/deleters, or whose | |
| body is a bare stub (``...``, ``pass``, or ``raise NotImplementedError``) are | |
| exempt from docstring/lazy/raises checks but still subject to typing checks. | |
| Usage: | |
| python doc_check.py src/ # check a directory (lazy on) | |
| python doc_check.py src/ tests/ a.py # multiple paths | |
| python doc_check.py src/ --no-check-lazy # disable lazy checks | |
| python doc_check.py src/ --check-raises # also require Raises: sections | |
| python doc_check.py src/ --format json # machine-readable output | |
| python doc_check.py --help | |
| Dependencies: | |
| None. stdlib only. | |
| Note: docstrings in this script are deliberately thorough, 'practice what you | |
| preach' etc. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import ast | |
| import fnmatch | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import tokenize | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from pathlib import Path | |
| from typing import Iterator | |
| __version__ = "2.1.0" | |
| # --------------------------------------------------------------------------- | |
| # Default exclusions for Python / mixed workspaces | |
| # --------------------------------------------------------------------------- | |
| # Exact directory names to skip. Matched against any path component. | |
| _DEFAULT_SKIP_DIRS: frozenset[str] = frozenset({ | |
| # Version control | |
| ".git", | |
| ".hg", | |
| ".svn", | |
| ".bzr", | |
| # Python virtual envs | |
| ".venv", | |
| "venv", | |
| "env", | |
| ".env", | |
| "virtualenv", | |
| # Python caches / tool state | |
| "__pycache__", | |
| ".mypy_cache", | |
| ".pytest_cache", | |
| ".ruff_cache", | |
| ".pytype", | |
| ".hypothesis", | |
| ".tox", | |
| ".nox", | |
| # Build / install artifacts | |
| "build", | |
| "dist", | |
| "_build", | |
| "buck-out", | |
| ".eggs", | |
| "htmlcov", | |
| "site-packages", | |
| "__pypackages__", | |
| # Notebooks / editor / tooling | |
| ".ipynb_checkpoints", | |
| ".idea", | |
| ".vscode", | |
| ".direnv", | |
| ".cache", | |
| # JS/Node (common in mixed workspaces) | |
| "node_modules", | |
| ".next", | |
| ".nuxt", | |
| ".svelte-kit", | |
| ".turbo", | |
| }) | |
| # Glob patterns matched against any path component (e.g. "mypkg.egg-info"). | |
| _DEFAULT_SKIP_GLOBS: tuple[str, ...] = ( | |
| "*.egg-info", | |
| "*.dist-info", | |
| ) | |
| # Exact filenames to skip regardless of directory. | |
| _DEFAULT_SKIP_FILES: frozenset[str] = frozenset({ | |
| "_version.py", | |
| "conftest_generated.py", | |
| }) | |
| # Valid issue-type names, for --ignore validation and documentation. | |
| _ISSUE_TYPES: tuple[str, ...] = ( | |
| "docstring", | |
| "lazy_docstring", | |
| "raises_docstring", | |
| "return_type", | |
| "param_type", | |
| "syntax", | |
| "error", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Terminal color helpers + fallback | |
| # --------------------------------------------------------------------------- | |
| # Mutable module state; finalized once in main() via _resolve_color(). | |
| _USE_COLOR = sys.stdout.isatty() | |
| def _resolve_color(no_color: bool, to_json: bool) -> bool: | |
| """Decide whether ANSI color should be emitted. | |
| Precedence: an explicit ``--no-color`` (or JSON output) wins, then the | |
| ``NO_COLOR`` env var, then ``FORCE_COLOR``, then TTY detection. This follows | |
| the informal https://no-color.org convention. | |
| Args: | |
| no_color: True if ``--no-color`` was passed. | |
| to_json: True if output format is JSON (color is never emitted then). | |
| Returns: | |
| True if color output should be enabled. | |
| """ | |
| if no_color or to_json: | |
| return False | |
| if os.environ.get("NO_COLOR"): | |
| return False | |
| if os.environ.get("FORCE_COLOR"): | |
| return True | |
| return sys.stdout.isatty() | |
| def _c(code: str, text: str) -> str: | |
| """Wrap text in an ANSI escape sequence, or return plain text if color is disabled. | |
| Args: | |
| code: ANSI escape code (e.g. '91' for bright red). | |
| text: The string to format. | |
| Returns: | |
| ANSI-formatted string, or the original text if color is disabled. | |
| """ | |
| if not _USE_COLOR: | |
| return text | |
| return f"\033[{code}m{text}\033[0m" | |
| def red(t: str) -> str: | |
| """Return text formatted in bright red. | |
| Args: | |
| t: The string to format. | |
| Returns: | |
| ANSI bright-red formatted string. | |
| """ | |
| return _c("91", t) | |
| def yellow(t: str) -> str: | |
| """Return text formatted in bright yellow. | |
| Args: | |
| t: The string to format. | |
| Returns: | |
| ANSI bright-yellow formatted string. | |
| """ | |
| return _c("93", t) | |
| def green(t: str) -> str: | |
| """Return text formatted in bright green. | |
| Args: | |
| t: The string to format. | |
| Returns: | |
| ANSI bright-green formatted string. | |
| """ | |
| return _c("92", t) | |
| def cyan(t: str) -> str: | |
| """Return text formatted in bright cyan. | |
| Args: | |
| t: The string to format. | |
| Returns: | |
| ANSI bright-cyan formatted string. | |
| """ | |
| return _c("96", t) | |
| def bold(t: str) -> str: | |
| """Return text formatted in bold. | |
| Args: | |
| t: The string to format. | |
| Returns: | |
| ANSI bold formatted string. | |
| """ | |
| return _c("1", t) | |
| def dim(t: str) -> str: | |
| """Return text formatted in dim/faint. | |
| Args: | |
| t: The string to format. | |
| Returns: | |
| ANSI dim formatted string. | |
| """ | |
| return _c("2", t) | |
| # Map issue types to colors. | |
| _ISSUE_COLORS = { | |
| "docstring": yellow, | |
| "lazy_docstring": yellow, | |
| "raises_docstring": yellow, | |
| "return_type": red, | |
| "param_type": red, | |
| "syntax": red, | |
| "error": red, | |
| } | |
| def _pretty_path(path: Path) -> str: | |
| """Return a human-readable absolute path, using ~/... when under home. | |
| Args: | |
| path: Resolved absolute path to display. | |
| Returns: | |
| Path string with home directory replaced by '~' where applicable. | |
| """ | |
| try: | |
| return "~/" + str(path.relative_to(Path.home())) | |
| except ValueError: | |
| return str(path) | |
| def _display_path(filepath: Path, roots: list[Path]) -> str: | |
| """Return the most concise, unambiguous display path for a file. | |
| Tries, in order: relative to each scan root, relative to the current | |
| working directory, home-relative (``~/...``), then the absolute path. The | |
| shortest candidate that contains no ``..`` chain is chosen. | |
| Args: | |
| filepath: The resolved file whose display path is being computed. | |
| roots: The resolved scan roots (files or directories) supplied on the CLI. | |
| Returns: | |
| A concise string path suitable for terminal or JSON output. | |
| """ | |
| candidates: list[str] = [] | |
| bases = [r if r.is_dir() else r.parent for r in roots] | |
| bases.append(Path.cwd()) | |
| for base in bases: | |
| try: | |
| rel = filepath.relative_to(base) | |
| except ValueError: | |
| continue | |
| candidates.append(str(rel)) | |
| candidates.append(_pretty_path(filepath)) | |
| candidates.append(str(filepath)) | |
| clean = [c for c in candidates if ".." not in c.split(os.sep)] | |
| pool = clean or candidates | |
| return min(pool, key=len) | |
| # --------------------------------------------------------------------------- | |
| # Data models | |
| # --------------------------------------------------------------------------- | |
| class CheckMode(Enum): | |
| """Available checking modes.""" | |
| DOCSTRING = "docstring" | |
| TYPING = "typing" | |
| BOTH = "both" | |
| @dataclass | |
| class Issue: | |
| """Represents a single linting issue.""" | |
| filepath: Path | |
| name: str | |
| line: int | |
| issue_type: str | |
| detail: str | |
| @dataclass | |
| class CheckResult: | |
| """Aggregated results for a file.""" | |
| filepath: Path | |
| issues: list[Issue] = field(default_factory=list) | |
| # --------------------------------------------------------------------------- | |
| # Docstring parsing | |
| # --------------------------------------------------------------------------- | |
| @dataclass | |
| class DocstringInfo: | |
| """Parsed information from a docstring.""" | |
| raw: str | |
| summary: str = "" | |
| body: str = "" | |
| documented_params: set[str] = field(default_factory=set) | |
| has_return_doc: bool = False | |
| has_raises_doc: bool = False | |
| @classmethod | |
| def parse(cls, docstring: str | None) -> "DocstringInfo | None": | |
| """Parse a docstring and extract structured information. | |
| Style-agnostic: detects Google, NumPy, Sphinx, and freeform styles | |
| by checking if param names actually appear with descriptions. | |
| Args: | |
| docstring: The raw docstring text to parse. | |
| Returns: | |
| DocstringInfo object with parsed details, or None if docstring is None. | |
| """ | |
| if docstring is None: | |
| return None | |
| lines = docstring.strip().split("\n") | |
| summary_lines: list[str] = [] | |
| body_start = 0 | |
| for i, line in enumerate(lines): | |
| if line.strip() == "": | |
| body_start = i + 1 | |
| break | |
| summary_lines.append(line) | |
| else: | |
| body_start = len(lines) | |
| summary = " ".join(summary_lines) | |
| body = "\n".join(lines[body_start:]) | |
| info = cls(raw=docstring, summary=summary, body=body) | |
| info.documented_params = cls._extract_documented_params(docstring, body) | |
| info.has_return_doc = cls._has_return_documentation(body) | |
| info.has_raises_doc = cls._has_raises_documentation(body) | |
| return info | |
| @staticmethod | |
| def _extract_documented_params(full_doc: str, body: str) -> set[str]: | |
| """Extract parameter names that appear to be documented. | |
| Args: | |
| full_doc: Complete docstring text. | |
| body: Docstring body (after summary paragraph). | |
| Returns: | |
| Set of lowercase parameter names that are documented. | |
| """ | |
| documented: set[str] = set() | |
| # Sphinx: :param name: or :param type name: | |
| for match in re.finditer(r":param\s+([^:]+):", full_doc): | |
| parts = match.group(1).strip().split() | |
| if parts: | |
| documented.add(parts[-1].lower()) | |
| # Google: indented `name:` or `name (type):` with description. | |
| for match in re.finditer( | |
| r"^\s{2,}(\*{0,2}\w+)\s*(?:\([^)]*\))?\s*:", body, re.MULTILINE | |
| ): | |
| name = match.group(1).lstrip("*") | |
| documented.add(name.lower()) | |
| # NumPy: `name : type` | |
| for match in re.finditer(r"^\s*(\w+)\s+:\s+\S+", body, re.MULTILINE): | |
| documented.add(match.group(1).lower()) | |
| return documented | |
| @staticmethod | |
| def _has_return_documentation(body: str) -> bool: | |
| """Check if return value is documented in the body. | |
| Args: | |
| body: Docstring body (after summary paragraph). | |
| Returns: | |
| True if any return/yield documentation pattern is found. | |
| """ | |
| patterns = [ | |
| r":returns?\s*[^:]*:", | |
| r":rtype\s*:", | |
| r"^\s*returns?\s*[:\-]", | |
| r"^\s*returns?\s*\n\s*-{3,}", | |
| r"^\s*yields?\s*[:\-]", | |
| r"^\s*yields?\s*\n\s*-{3,}", | |
| ] | |
| for pattern in patterns: | |
| if re.search(pattern, body, re.MULTILINE | re.IGNORECASE): | |
| return True | |
| return False | |
| @staticmethod | |
| def _has_raises_documentation(body: str) -> bool: | |
| """Check if raised exceptions are documented in the body. | |
| Args: | |
| body: Docstring body (after summary paragraph). | |
| Returns: | |
| True if any raises documentation pattern (Google, NumPy, or | |
| Sphinx style) is found. | |
| """ | |
| patterns = [ | |
| r":raises?\s*[^:]*:", | |
| r"^\s*raises?\s*[:\-]", | |
| r"^\s*raises?\s*\n\s*-{3,}", | |
| ] | |
| for pattern in patterns: | |
| if re.search(pattern, body, re.MULTILINE | re.IGNORECASE): | |
| return True | |
| return False | |
| def is_param_documented(self, param_name: str) -> bool: | |
| """Check if a specific parameter is documented. | |
| Args: | |
| param_name: Name of the parameter to check. | |
| Returns: | |
| True if the parameter appears in documented_params. | |
| """ | |
| return param_name.lstrip("*").lower() in self.documented_params | |
| # --------------------------------------------------------------------------- | |
| # Decorator / stub helpers | |
| # --------------------------------------------------------------------------- | |
| def _decorator_tail(dec: ast.expr) -> str: | |
| """Return the final identifier of a (possibly dotted, possibly called) decorator. | |
| ``@overload`` -> 'overload', ``@typing.overload`` -> 'overload', | |
| ``@x.setter`` -> 'setter', ``@app.route("/")`` -> 'route'. | |
| Args: | |
| dec: The decorator expression node. | |
| Returns: | |
| The trailing attribute/name of the decorator, or '' if unrecognized. | |
| """ | |
| if isinstance(dec, ast.Call): | |
| dec = dec.func | |
| if isinstance(dec, ast.Attribute): | |
| return dec.attr | |
| if isinstance(dec, ast.Name): | |
| return dec.id | |
| return "" | |
| def _decorator_tails(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: | |
| """Collect the trailing identifiers of all decorators on a function. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| Set of trailing decorator identifiers. | |
| """ | |
| return {_decorator_tail(d) for d in node.decorator_list} | |
| def _is_overload(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: | |
| """Return True if the function is a typing ``@overload`` stub. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| True if decorated with ``overload`` / ``typing.overload``. | |
| """ | |
| return "overload" in _decorator_tails(node) | |
| def _is_property_accessor(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: | |
| """Return True for a ``@x.setter`` or ``@x.deleter`` accessor. | |
| Such accessors conventionally carry no docstring of their own (the getter | |
| documents the property), so they are exempt from docstring/lazy checks. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| True if the function is a property setter or deleter. | |
| """ | |
| tails = _decorator_tails(node) | |
| return "setter" in tails or "deleter" in tails | |
| def _is_property_getter(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: | |
| """Return True for a ``@property`` getter. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| True if decorated with ``@property``. | |
| """ | |
| return "property" in _decorator_tails(node) | |
| def _is_stub_body(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: | |
| """Return True if the function body is a bare stub. | |
| A stub is a body consisting only of ``...``, ``pass``, or | |
| ``raise NotImplementedError`` (optionally preceded by a docstring). Such | |
| functions are placeholders and are exempt from docstring/lazy checks. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| True if the body is an empty placeholder stub. | |
| """ | |
| body = list(node.body) | |
| if ( | |
| body | |
| and isinstance(body[0], ast.Expr) | |
| and isinstance(body[0].value, ast.Constant) | |
| and isinstance(body[0].value.value, str) | |
| ): | |
| body = body[1:] # drop leading docstring | |
| if len(body) != 1: | |
| return False | |
| stmt = body[0] | |
| if isinstance(stmt, ast.Pass): | |
| return True | |
| if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant): | |
| return stmt.value.value is Ellipsis | |
| if isinstance(stmt, ast.Raise): | |
| exc = stmt.exc | |
| if isinstance(exc, ast.Call): | |
| exc = exc.func | |
| return _decorator_tail(exc) == "NotImplementedError" if exc else False | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # AST checker | |
| # --------------------------------------------------------------------------- | |
| class DocstringTypingChecker(ast.NodeVisitor): | |
| """AST visitor that checks for missing docstrings, type hints, and lazy docstrings.""" | |
| def __init__( | |
| self, | |
| filepath: Path, | |
| mode: CheckMode, | |
| check_lazy: bool = True, | |
| check_raises: bool = False, | |
| ) -> None: | |
| """Initialize checker with filepath and mode. | |
| Args: | |
| filepath: Path to the file being checked. | |
| mode: The checking mode (docstring, typing, or both). | |
| check_lazy: Whether to check for lazy/insufficient docstrings. | |
| check_raises: Whether to require a ``Raises:`` section for functions | |
| that explicitly raise an exception. | |
| """ | |
| self.filepath = filepath | |
| self.mode = mode | |
| self.check_lazy = check_lazy | |
| self.check_raises = check_raises | |
| self.issues: list[Issue] = [] | |
| self._scope: list[str] = [] | |
| def _qualified_name(self, name: str) -> str: | |
| """Return the fully qualified name including all enclosing scopes. | |
| Args: | |
| name: The base name of the function or class. | |
| Returns: | |
| Dotted name prefixed by every enclosing class/function scope. | |
| """ | |
| return ".".join(self._scope + [name]) | |
| def _should_check_docstrings(self) -> bool: | |
| """Return True if docstring checks are enabled for the current mode. | |
| Returns: | |
| True when mode is DOCSTRING or BOTH. | |
| """ | |
| return self.mode in (CheckMode.DOCSTRING, CheckMode.BOTH) | |
| def _should_check_typing(self) -> bool: | |
| """Return True if type hint checks are enabled for the current mode. | |
| Returns: | |
| True when mode is TYPING or BOTH. | |
| """ | |
| return self.mode in (CheckMode.TYPING, CheckMode.BOTH) | |
| def _add_issue(self, name: str, line: int, issue_type: str, detail: str) -> None: | |
| """Record an issue found during checking. | |
| Args: | |
| name: Qualified name of the entity with the issue. | |
| line: Line number where the issue occurs. | |
| issue_type: Category of the issue (e.g. docstring, param_type). | |
| detail: Human-readable description of the problem. | |
| """ | |
| self.issues.append( | |
| Issue( | |
| filepath=self.filepath, | |
| name=name, | |
| line=line, | |
| issue_type=issue_type, | |
| detail=detail, | |
| ) | |
| ) | |
| def _get_real_params( | |
| self, node: ast.FunctionDef | ast.AsyncFunctionDef | |
| ) -> list[str]: | |
| """Extract parameter names excluding self/cls. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| List of parameter names, with vararg/kwarg prefixed by */**. | |
| """ | |
| args = node.args | |
| all_args = args.posonlyargs + args.args + args.kwonlyargs | |
| params = [arg.arg for arg in all_args if arg.arg not in ("self", "cls")] | |
| if args.vararg: | |
| params.append(f"*{args.vararg.arg}") | |
| if args.kwarg: | |
| params.append(f"**{args.kwarg.arg}") | |
| return params | |
| def _returns_and_yields( | |
| self, node: ast.FunctionDef | ast.AsyncFunctionDef | |
| ) -> tuple[bool, bool]: | |
| """Detect meaningful returns and yields within a function body. | |
| Does not descend into nested function definitions, so a return inside a | |
| closure is attributed to the closure, not to ``node``. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| A ``(has_value_return, has_yield)`` tuple. ``has_value_return`` is | |
| True only for ``return`` statements with a non-None value. | |
| """ | |
| has_return = False | |
| has_yield = False | |
| to_visit = list(ast.iter_child_nodes(node)) | |
| while to_visit: | |
| child = to_visit.pop() | |
| if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): | |
| continue | |
| if isinstance(child, ast.Return) and child.value is not None: | |
| if not ( | |
| isinstance(child.value, ast.Constant) and child.value.value is None | |
| ): | |
| has_return = True | |
| elif isinstance(child, (ast.Yield, ast.YieldFrom)): | |
| has_yield = True | |
| to_visit.extend(ast.iter_child_nodes(child)) | |
| return has_return, has_yield | |
| def _explicit_raises(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: | |
| """Detect an explicit ``raise Exc(...)`` in a function body. | |
| Bare re-raises (``raise`` with no exception, used inside ``except``) are | |
| ignored, and nested function definitions are not descended into, so a | |
| raise inside a closure is attributed to the closure, not to ``node``. | |
| Args: | |
| node: The function definition AST node. | |
| Returns: | |
| True if the body contains at least one explicit exception raise. | |
| """ | |
| to_visit = list(ast.iter_child_nodes(node)) | |
| while to_visit: | |
| child = to_visit.pop() | |
| if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): | |
| continue | |
| if isinstance(child, ast.Raise) and child.exc is not None: | |
| return True | |
| to_visit.extend(ast.iter_child_nodes(child)) | |
| return False | |
| def _check_lazy_docstring( | |
| self, | |
| node: ast.FunctionDef | ast.AsyncFunctionDef, | |
| qualified_name: str, | |
| doc_info: DocstringInfo, | |
| exempt_return: bool, | |
| ) -> None: | |
| """Check whether an existing docstring under-documents the function. | |
| Flags two situations: | |
| * The function has 2+ real parameters and not all are documented. | |
| * The function takes at least one parameter (or is a generator) and | |
| returns/yields a value with no ``Returns:``/``Yields:`` section. | |
| Zero-argument accessors are intentionally not required to carry a | |
| ``Returns:`` section, since their summary line typically suffices. | |
| Args: | |
| node: The function definition AST node. | |
| qualified_name: The fully qualified name of the function. | |
| doc_info: The parsed docstring for the function. | |
| exempt_return: If True, skip the missing-return-section check | |
| (used for ``@property`` getters). | |
| """ | |
| params = self._get_real_params(node) | |
| has_return, has_yield = self._returns_and_yields(node) | |
| produces = (has_return or has_yield) and node.name != "__init__" | |
| issues_found: list[str] = [] | |
| if len(params) >= 2: | |
| undocumented = [ | |
| p for p in params if not doc_info.is_param_documented(p.lstrip("*")) | |
| ] | |
| if undocumented: | |
| if len(undocumented) == len(params): | |
| issues_found.append(f"has {len(params)} params but none documented") | |
| else: | |
| issues_found.append(f"missing docs for: {', '.join(undocumented)}") | |
| needs_return_doc = ( | |
| produces | |
| and not exempt_return | |
| and (len(params) >= 1 or has_yield) | |
| and not doc_info.has_return_doc | |
| ) | |
| if needs_return_doc: | |
| kind = "Yields" if has_yield and not has_return else "Returns" | |
| issues_found.append(f"produces a value but no {kind} section") | |
| if issues_found: | |
| self._add_issue( | |
| qualified_name, | |
| node.lineno, | |
| "lazy_docstring", | |
| "; ".join(issues_found), | |
| ) | |
| def _check_raises_docstring( | |
| self, | |
| node: ast.FunctionDef | ast.AsyncFunctionDef, | |
| qualified_name: str, | |
| doc_info: DocstringInfo, | |
| ) -> None: | |
| """Flag a docstring that omits a ``Raises:`` section for an explicit raise. | |
| Only explicit ``raise Exc(...)`` statements in the function's own body | |
| are considered; bare re-raises and exceptions from callees are not | |
| detected. Opt-in via ``--check-raises``. | |
| Args: | |
| node: The function definition AST node. | |
| qualified_name: The fully qualified name of the function. | |
| doc_info: The parsed docstring for the function. | |
| """ | |
| if not self._explicit_raises(node): | |
| return | |
| if doc_info.has_raises_doc: | |
| return | |
| self._add_issue( | |
| qualified_name, | |
| node.lineno, | |
| "raises_docstring", | |
| "raises an exception but no Raises section", | |
| ) | |
| def _check_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: | |
| """Check a function/method for docstring and type hint issues. | |
| Args: | |
| node: The function definition AST node to check. | |
| """ | |
| qualified_name = self._qualified_name(node.name) | |
| # Skip dunder methods entirely except __init__ (matches convention that | |
| # dunders are self-documenting and typed by the protocol they implement). | |
| if ( | |
| node.name.startswith("__") | |
| and node.name.endswith("__") | |
| and node.name != "__init__" | |
| ): | |
| return | |
| is_overload = _is_overload(node) | |
| is_accessor = _is_property_accessor(node) | |
| is_getter = _is_property_getter(node) | |
| is_stub = _is_stub_body(node) | |
| doc_exempt = is_overload or is_accessor or is_stub | |
| if self._should_check_docstrings(): | |
| docstring = ast.get_docstring(node) | |
| if not doc_exempt and docstring is None: | |
| self._add_issue( | |
| qualified_name, node.lineno, "docstring", "Missing docstring" | |
| ) | |
| if ( | |
| not doc_exempt | |
| and docstring is not None | |
| and (self.check_lazy or self.check_raises) | |
| ): | |
| doc_info = DocstringInfo.parse(docstring) | |
| if doc_info is not None: | |
| if self.check_lazy: | |
| self._check_lazy_docstring( | |
| node, qualified_name, doc_info, exempt_return=is_getter | |
| ) | |
| if self.check_raises: | |
| self._check_raises_docstring(node, qualified_name, doc_info) | |
| if self._should_check_typing(): | |
| if node.name != "__init__" and node.returns is None: | |
| self._add_issue( | |
| qualified_name, | |
| node.lineno, | |
| "return_type", | |
| "Missing return type hint", | |
| ) | |
| args = node.args | |
| all_args = args.posonlyargs + args.args + args.kwonlyargs | |
| for arg in all_args: | |
| if arg.arg in ("self", "cls"): | |
| continue | |
| if arg.annotation is None: | |
| self._add_issue( | |
| qualified_name, | |
| getattr(arg, "lineno", node.lineno), | |
| "param_type", | |
| f"Missing type hint for parameter '{arg.arg}'", | |
| ) | |
| if args.vararg and args.vararg.annotation is None: | |
| self._add_issue( | |
| qualified_name, | |
| getattr(args.vararg, "lineno", node.lineno), | |
| "param_type", | |
| f"Missing type hint for *{args.vararg.arg}", | |
| ) | |
| if args.kwarg and args.kwarg.annotation is None: | |
| self._add_issue( | |
| qualified_name, | |
| getattr(args.kwarg, "lineno", node.lineno), | |
| "param_type", | |
| f"Missing type hint for **{args.kwarg.arg}", | |
| ) | |
| def visit_FunctionDef(self, node: ast.FunctionDef) -> None: | |
| """Visit a function definition node. | |
| Args: | |
| node: The FunctionDef AST node. | |
| """ | |
| self._check_function(node) | |
| self._scope.append(node.name) | |
| self.generic_visit(node) | |
| self._scope.pop() | |
| def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: | |
| """Visit an async function definition node. | |
| Args: | |
| node: The AsyncFunctionDef AST node. | |
| """ | |
| self._check_function(node) | |
| self._scope.append(node.name) | |
| self.generic_visit(node) | |
| self._scope.pop() | |
| def visit_ClassDef(self, node: ast.ClassDef) -> None: | |
| """Visit a class definition node. | |
| Args: | |
| node: The ClassDef AST node. | |
| """ | |
| if self._should_check_docstrings() and not ast.get_docstring(node): | |
| self._add_issue( | |
| self._qualified_name(node.name), | |
| node.lineno, | |
| "docstring", | |
| "Missing class docstring", | |
| ) | |
| self._scope.append(node.name) | |
| self.generic_visit(node) | |
| self._scope.pop() | |
| # --------------------------------------------------------------------------- | |
| # File checking | |
| # --------------------------------------------------------------------------- | |
| def check_file( | |
| filepath: Path, | |
| mode: CheckMode, | |
| check_lazy: bool = True, | |
| check_raises: bool = False, | |
| ) -> CheckResult: | |
| """Parse and check a single Python file. | |
| Args: | |
| filepath: Path to the Python file to check. | |
| mode: The checking mode to use. | |
| check_lazy: Whether to check for lazy docstrings. | |
| check_raises: Whether to require a ``Raises:`` section for functions | |
| that explicitly raise an exception. | |
| Returns: | |
| CheckResult containing all issues found in the file. | |
| """ | |
| result = CheckResult(filepath=filepath) | |
| try: | |
| # tokenize.open respects PEP 263 coding declarations and strips a BOM. | |
| with tokenize.open(filepath) as fh: | |
| source = fh.read() | |
| except Exception as e: # noqa: BLE001 - report any read/decode failure uniformly | |
| result.issues.append( | |
| Issue(filepath, "<module>", 1, "error", f"Could not read file: {e}") | |
| ) | |
| return result | |
| try: | |
| tree = ast.parse(source, filename=str(filepath)) | |
| except SyntaxError as e: | |
| result.issues.append( | |
| Issue( | |
| filepath, "<module>", e.lineno or 1, "syntax", f"Syntax error: {e.msg}" | |
| ) | |
| ) | |
| return result | |
| if mode in (CheckMode.DOCSTRING, CheckMode.BOTH) and not ast.get_docstring(tree): | |
| result.issues.append( | |
| Issue(filepath, "<module>", 1, "docstring", "Missing module docstring") | |
| ) | |
| checker = DocstringTypingChecker(filepath, mode, check_lazy, check_raises) | |
| checker.visit(tree) | |
| result.issues.extend(checker.issues) | |
| return result | |
| def find_python_files( | |
| directory: Path, exclude_patterns: list[str] | None = None | |
| ) -> Iterator[Path]: | |
| """Recursively find all Python files in a directory. | |
| Built-in default exclusions (see ``_DEFAULT_SKIP_DIRS`` / ``_DEFAULT_SKIP_GLOBS``) | |
| cover typical Python and mixed-language workspace clutter: VCS dirs, virtual | |
| envs, caches (``__pycache__``, ``.mypy_cache``, ``.pytest_cache``, | |
| ``.ruff_cache``, ``.tox``, ``.hypothesis``, ...), build output | |
| (``build``, ``dist``, ``*.egg-info``, ``site-packages``, ``__pypackages__``), | |
| notebook checkpoints, editor state, and ``node_modules``. | |
| User-supplied ``exclude_patterns`` are added on top and may be either exact | |
| directory names or glob patterns (e.g. ``generated_*``, ``*.bak``). | |
| Args: | |
| directory: Root directory to search. | |
| exclude_patterns: Additional directory names or glob patterns to exclude. | |
| Yields: | |
| Path objects for each Python file found. | |
| Raises: | |
| ValueError: If ``directory`` is not an existing directory. | |
| """ | |
| if not directory.is_dir(): | |
| raise ValueError(f"Expected a directory, got: {directory}") | |
| user_patterns = exclude_patterns or [] | |
| # Split user patterns into literals (for O(1) set lookup) and globs. | |
| _GLOB_CHARS = set("*?[") | |
| user_literals = {p for p in user_patterns if not any(c in p for c in _GLOB_CHARS)} | |
| user_globs = tuple(p for p in user_patterns if any(c in p for c in _GLOB_CHARS)) | |
| skip_dirs = _DEFAULT_SKIP_DIRS | user_literals | |
| skip_globs = _DEFAULT_SKIP_GLOBS + user_globs | |
| def _is_skipped(part: str) -> bool: | |
| """Check whether a single path component matches any skip rule. | |
| Args: | |
| part: A single path component (directory or file name). | |
| Returns: | |
| True if ``part`` matches a literal skip name or any skip glob. | |
| """ | |
| if part in skip_dirs: | |
| return True | |
| return any(fnmatch.fnmatchcase(part, pat) for pat in skip_globs) | |
| for path in directory.rglob("*.py"): | |
| if any(_is_skipped(part) for part in path.parts): | |
| continue | |
| if path.name in _DEFAULT_SKIP_FILES: | |
| continue | |
| yield path | |
| def collect_files( | |
| targets: list[Path], exclude_patterns: list[str] | |
| ) -> tuple[list[Path], list[str]]: | |
| """Resolve CLI targets into a deduplicated, sorted list of Python files. | |
| Args: | |
| targets: Files and/or directories supplied on the command line. | |
| exclude_patterns: Extra exclusion patterns forwarded to directory scans. | |
| Returns: | |
| A ``(files, errors)`` tuple. ``files`` is the sorted, unique set of | |
| ``.py`` files to check; ``errors`` holds human-readable messages for | |
| targets that could not be used (missing paths, non-Python files). | |
| """ | |
| seen: dict[Path, None] = {} | |
| errors: list[str] = [] | |
| for target in targets: | |
| resolved = target.resolve() | |
| if not resolved.exists(): | |
| errors.append(f"path does not exist: {target}") | |
| continue | |
| if resolved.is_file(): | |
| if resolved.suffix != ".py": | |
| errors.append(f"not a Python file: {target}") | |
| continue | |
| seen[resolved] = None | |
| else: | |
| for f in find_python_files(resolved, exclude_patterns): | |
| seen[f.resolve()] = None | |
| return sorted(seen), errors | |
| # --------------------------------------------------------------------------- | |
| # Output formatting | |
| # --------------------------------------------------------------------------- | |
| def _summarize(results: list[CheckResult]) -> tuple[int, int, dict[str, int]]: | |
| """Aggregate issue counts across results. | |
| Args: | |
| results: The per-file check results. | |
| Returns: | |
| A ``(files_with_issues, total_issues, by_type)`` tuple. | |
| """ | |
| files_with_issues = 0 | |
| total_issues = 0 | |
| by_type: dict[str, int] = {} | |
| for result in results: | |
| if result.issues: | |
| files_with_issues += 1 | |
| total_issues += len(result.issues) | |
| for issue in result.issues: | |
| by_type[issue.issue_type] = by_type.get(issue.issue_type, 0) + 1 | |
| return files_with_issues, total_issues, by_type | |
| def format_text( | |
| results: list[CheckResult], | |
| roots: list[Path], | |
| verbose: bool = False, | |
| ) -> str: | |
| """Format issues for human-readable terminal output. | |
| Args: | |
| results: List of CheckResult objects to format. | |
| roots: The scan roots used to compute concise display paths. | |
| verbose: Whether to show files without issues. | |
| Returns: | |
| Formatted string ready for terminal output. | |
| """ | |
| lines: list[str] = [] | |
| for result in results: | |
| dpath = _display_path(result.filepath, roots) | |
| if not result.issues: | |
| if verbose: | |
| lines.append(green(f" OK {dpath}")) | |
| continue | |
| lines.append(f"\n{bold(dpath)}") | |
| lines.append(dim("─" * min(len(dpath) + 2, 80))) | |
| ordered = sorted(result.issues, key=lambda i: (i.line, i.issue_type, i.name)) | |
| for issue in ordered: | |
| color_fn = _ISSUE_COLORS.get(issue.issue_type, cyan) | |
| type_label = color_fn(f"{issue.issue_type:<16}") | |
| lines.append( | |
| f" {dim(f'L{issue.line:4d}')} {type_label} " | |
| f"{cyan(issue.name)}: {issue.detail}" | |
| ) | |
| files_with_issues, total_issues, by_type = _summarize(results) | |
| lines.append(f"\n{bold('=' * 60)}") | |
| lines.append(f"Files scanned : {len(results)}") | |
| if files_with_issues: | |
| lines.append(red(f"Files w/ issues: {files_with_issues}")) | |
| lines.append(red(f"Total issues : {total_issues}")) | |
| lines.append(f"\n{bold('Breakdown by type:')}") | |
| for issue_type, count in sorted(by_type.items(), key=lambda x: -x[1]): | |
| color_fn = _ISSUE_COLORS.get(issue_type, cyan) | |
| lines.append(f" {color_fn(f'{issue_type:<16}')} {count}") | |
| else: | |
| lines.append(green("No issues found.")) | |
| return "\n".join(lines) | |
| def format_json(results: list[CheckResult], roots: list[Path]) -> str: | |
| """Format issues as a JSON document for machine consumption. | |
| Args: | |
| results: List of CheckResult objects to format. | |
| roots: The scan roots used to compute concise display paths. | |
| Returns: | |
| A pretty-printed JSON string with ``summary`` and ``issues`` keys. | |
| """ | |
| files_with_issues, total_issues, by_type = _summarize(results) | |
| issues: list[dict[str, object]] = [] | |
| for result in results: | |
| dpath = _display_path(result.filepath, roots) | |
| for issue in result.issues: | |
| issues.append({ | |
| "file": dpath, | |
| "name": issue.name, | |
| "line": issue.line, | |
| "type": issue.issue_type, | |
| "detail": issue.detail, | |
| }) | |
| issues.sort(key=lambda i: (i["file"], i["line"], i["type"], i["name"])) | |
| payload = { | |
| "summary": { | |
| "files_scanned": len(results), | |
| "files_with_issues": files_with_issues, | |
| "total_issues": total_issues, | |
| "by_type": dict(sorted(by_type.items(), key=lambda x: -x[1])), | |
| }, | |
| "issues": issues, | |
| } | |
| return json.dumps(payload, indent=2) | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| class _DeprecatedNoOp(argparse.Action): | |
| """Argparse action for a retired flag: warn once, otherwise do nothing.""" | |
| def __init__(self, option_strings: list[str], dest: str, **kwargs: object) -> None: | |
| """Force zero-arg behavior for the deprecated flag. | |
| Args: | |
| option_strings: The option strings this action handles. | |
| dest: The (suppressed) destination attribute. | |
| **kwargs: Remaining argparse action keyword arguments. | |
| """ | |
| kwargs["nargs"] = 0 | |
| super().__init__(option_strings, dest, **kwargs) # type: ignore[arg-type] | |
| def __call__( | |
| self, | |
| parser: argparse.ArgumentParser, | |
| namespace: argparse.Namespace, | |
| values: object, | |
| option_string: str | None = None, | |
| ) -> None: | |
| """Emit a deprecation notice and leave parsing state unchanged. | |
| Args: | |
| parser: The active argument parser. | |
| namespace: The namespace being populated. | |
| values: Unused (the flag takes no arguments). | |
| option_string: The specific option string the user typed. | |
| """ | |
| print( | |
| f"warning: {option_string} is deprecated and now a no-op; " | |
| "lazy-docstring checks run by default (use --no-check-lazy to disable).", | |
| file=sys.stderr, | |
| ) | |
| def get_parser() -> argparse.ArgumentParser: | |
| """Build and return the argument parser. | |
| Returns: | |
| Configured ArgumentParser instance. | |
| """ | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Check Python files for missing docstrings, type hints, and lazy " | |
| "docstrings. Lazy-docstring checks run by default." | |
| ), | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |
| ) | |
| parser.add_argument( | |
| "path", | |
| type=Path, | |
| nargs="+", | |
| help="One or more files or directories to check", | |
| ) | |
| parser.add_argument( | |
| "-m", | |
| "--mode", | |
| choices=["docstring", "typing", "both"], | |
| default="both", | |
| help="What to check for", | |
| ) | |
| parser.add_argument( | |
| "-f", | |
| "--format", | |
| choices=["text", "json"], | |
| default="text", | |
| dest="fmt", | |
| help="Output format", | |
| ) | |
| parser.add_argument( | |
| "-v", | |
| "--verbose", | |
| action="store_true", | |
| help="Show files without issues (text format only)", | |
| ) | |
| parser.add_argument( | |
| "--exclude", | |
| nargs="*", | |
| default=[], | |
| metavar="PATTERN", | |
| help=( | |
| "Directory names or glob patterns to exclude, in addition to the " | |
| "built-in defaults (.git, __pycache__, .venv, build, dist, " | |
| "node_modules, *.egg-info, etc.). Globs match a single path " | |
| "component, e.g. 'generated_*' or 'vendor_*'. Place after the " | |
| "paths, or separate with '--', to avoid consuming them." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--ignore", | |
| nargs="*", | |
| default=[], | |
| metavar="TYPE", | |
| choices=_ISSUE_TYPES, | |
| help="Issue types to suppress from output and exit status.", | |
| ) | |
| parser.add_argument( | |
| "--strict", | |
| action="store_true", | |
| help="Exit with code 1 if any (non-ignored) issues are found", | |
| ) | |
| parser.add_argument( | |
| "--no-check-lazy", | |
| action="store_false", | |
| dest="check_lazy", | |
| help="Disable lazy-docstring checks (they run by default)", | |
| ) | |
| parser.add_argument( | |
| "--check-raises", | |
| action="store_true", | |
| dest="check_raises", | |
| help=( | |
| "Also flag functions that explicitly raise an exception but whose " | |
| "docstring has no Raises: section (off by default)" | |
| ), | |
| ) | |
| # Deprecated: lazy checking is now the default. Accepted so existing | |
| # invocations keep working; emits a one-line notice and does nothing. | |
| parser.add_argument( | |
| "--check-lazy-docstrings", | |
| "--check-lazy", | |
| action=_DeprecatedNoOp, | |
| dest=argparse.SUPPRESS, | |
| help="Deprecated no-op (lazy checks run by default)", | |
| ) | |
| parser.add_argument( | |
| "--no-color", | |
| action="store_true", | |
| dest="no_color", | |
| help="Disable ANSI color output", | |
| ) | |
| parser.add_argument( | |
| "--version", | |
| action="version", | |
| version=f"%(prog)s {__version__}", | |
| ) | |
| parser.set_defaults(check_lazy=True) | |
| return parser | |
| def main() -> int: | |
| """CLI entry point. | |
| Returns: | |
| Exit code (0 for success, 1 for issues in strict mode, 2 for usage errors). | |
| """ | |
| args = get_parser().parse_args() | |
| to_json = args.fmt == "json" | |
| global _USE_COLOR | |
| _USE_COLOR = _resolve_color(args.no_color, to_json) | |
| targets: list[Path] = args.path | |
| files, errors = collect_files(targets, args.exclude) | |
| for err in errors: | |
| print(f"{red('Error:')} {err}", file=sys.stderr) | |
| if not files: | |
| if not errors: | |
| print("No Python files found.", file=sys.stderr) | |
| return 0 | |
| return 2 | |
| if not to_json: | |
| roots_label = ", ".join(_pretty_path(t.resolve()) for t in targets) | |
| n = len(files) | |
| print(f"Checking {bold(roots_label)} ({n} file{'s' if n != 1 else ''})") | |
| mode = CheckMode(args.mode) | |
| results = [check_file(f, mode, args.check_lazy, args.check_raises) for f in files] | |
| if args.ignore: | |
| ignored = set(args.ignore) | |
| for result in results: | |
| result.issues = [i for i in result.issues if i.issue_type not in ignored] | |
| roots = [t.resolve() for t in targets] | |
| if to_json: | |
| print(format_json(results, roots)) | |
| else: | |
| print(format_text(results, roots, verbose=args.verbose)) | |
| had_issues = any(r.issues for r in results) | |
| if errors: | |
| return 2 | |
| if args.strict and had_issues: | |
| return 1 | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
example output/use: