Skip to content

Instantly share code, notes, and snippets.

@pszemraj
Last active July 8, 2026 05:35
Show Gist options
  • Select an option

  • Save pszemraj/23952e8712c8651fd086e0be62698b23 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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())
@pszemraj

pszemraj commented Mar 7, 2026

Copy link
Copy Markdown
Author

example output/use:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment