"""Level 0: static docstring linters, normalised into the finding model.
Two tools are shelled out to. ``ruff check --select D`` covers pydocstyle
(presence, summary lines, section formatting for the configured convention).
``pydoclint`` covers signature consistency (``DOC1xx``/``DOC2xx``/``DOC4xx``/
``DOC5xx``: undocumented or misnamed parameters, missing returns), which ruff
only previews a handful of; it is optional and skipped with a note when it is
not installed.
Findings keep the tool's own code as ``rule`` (``D102``, ``DOC101``) and name
the tool in ``tool``, so they never collide with ledger ids.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
from typing import Iterable, Iterator
from epythet.validation.model import Finding
LINT_LEVEL = 0
#: Docstring styles ruff's pydocstyle convention and pydoclint's ``--style`` both accept.
STYLES = ("google", "numpy", "sphinx")
#: ``D107`` (``__init__`` must have its own docstring) contradicts pydoclint's
#: ``DOC301`` (``__init__`` must NOT have one; its Args merge into the class
#: docstring) -- the house convention this repo's docstring-style skill already
#: documents. Only one side can pass, so the ruff side is dropped.
RUFF_D_IGNORE = ("D107",)
[docs]
def ruff_severity(code: str) -> str:
"""``D1xx`` (missing docstrings) are warnings; other ``D`` rules are style, so info.
>>> ruff_severity("D102"), ruff_severity("D205")
('warning', 'info')
"""
return "warning" if code.startswith("D1") else "info"
def _relative(filename: str, project_dir: Path) -> str:
try:
return Path(filename).resolve().relative_to(project_dir.resolve()).as_posix()
except ValueError:
return filename
[docs]
def run_ruff(
package_dir: Path, *, project_dir: Path, style: str = "google"
) -> tuple[list[Finding], list[str]]:
"""Run ``ruff check --select D`` and translate its JSON output.
Returns ``(findings, notes)``; ``notes`` explains a skipped run.
"""
exe = shutil.which("ruff")
if exe is None:
return [], [
"ruff not found: level 0 pydocstyle checks skipped (pip install 'epythet[validate]')"
]
if style not in STYLES:
raise ValueError(f"style must be one of {STYLES}, got {style!r}")
cmd = [
exe,
"check",
"--select",
"D",
"--output-format",
"json",
"--exit-zero",
"--no-cache",
"--config",
f"lint.pydocstyle.convention = '{style}'",
"--ignore",
",".join(RUFF_D_IGNORE),
str(package_dir),
]
proc = subprocess.run(cmd, cwd=project_dir, capture_output=True, text=True)
if proc.returncode not in (0, 1) or not proc.stdout.strip():
return [], [
f"ruff failed (exit {proc.returncode}): {proc.stderr.strip()[:300]}"
]
try:
diagnostics = json.loads(proc.stdout)
except json.JSONDecodeError:
return [], [f"ruff produced unparseable output: {proc.stdout[:200]!r}"]
findings = [
Finding(
rule=d["code"],
severity=ruff_severity(d["code"]),
level=LINT_LEVEL,
message=d["message"],
file=_relative(d["filename"], project_dir),
line=d.get("location", {}).get("row"),
detector="lint",
fix=(d.get("fix") or {}).get("message") or "",
autofixable=bool(d.get("fix")),
tool="ruff",
)
for d in diagnostics
if (d.get("code") or "").startswith("D")
]
return findings, []
_PYDOCLINT_LINE_RE = re.compile(r"^\s+(?P<line>\d+): (?P<code>DOC\d+): (?P<msg>.*)$")
#: pydoclint options for the house convention: types live in annotations, never
#: in the docstring. ``--arg-type-hints-in-signature true`` tells pydoclint that
#: *is* how a documented signature looks (DOC108 fires on the opposite reading:
#: ``false`` means "expect no type hints in the signature", which trips on every
#: annotated function). ``--arg-type-hints-in-docstring false`` keeps it from
#: asking for types in the docstring text. ``--allow-init-docstring`` defaults to
#: ``False``, which enforces DOC301 (``__init__`` undocumented, its Args merged
#: into the class docstring) -- the convention this house already writes to, so
#: it is left at its default rather than passed explicitly.
PYDOCLINT_OPTIONS = (
"--quiet",
"--skip-checking-short-docstrings",
"true",
"--arg-type-hints-in-docstring",
"false",
"--arg-type-hints-in-signature",
"true",
"--check-return-types",
"false",
"--check-yield-types",
"false",
)
[docs]
def pydoclint_severity(code: str) -> str:
"""``DOC1xx`` (arguments disagree with the signature) are warnings; the rest info.
>>> pydoclint_severity("DOC101"), pydoclint_severity("DOC201")
('warning', 'info')
"""
return "warning" if code.startswith("DOC1") else "info"
def _parse_pydoclint(output: str, project_dir: Path) -> Iterator[Finding]:
current_file: str | None = None
for raw in output.splitlines():
if not raw.strip():
continue
match = _PYDOCLINT_LINE_RE.match(raw)
if match is None:
if not raw.startswith(" "):
current_file = _relative(raw.strip(), project_dir)
continue
yield Finding(
rule=match["code"],
severity=pydoclint_severity(match["code"]),
level=LINT_LEVEL,
message=match["msg"].strip(),
file=current_file,
line=int(match["line"]),
detector="lint",
tool="pydoclint",
)
[docs]
def run_pydoclint(
package_dir: Path, *, project_dir: Path, style: str = "google"
) -> tuple[list[Finding], list[str]]:
"""Run ``pydoclint`` if installed; otherwise return a note and no findings.
pydoclint writes its report to stderr, so both streams are parsed.
"""
exe = shutil.which("pydoclint")
if exe is None:
return [], [
"pydoclint not installed: signature-consistency checks skipped (pip install pydoclint)"
]
cmd = [exe, "--style", style, *PYDOCLINT_OPTIONS, str(package_dir)]
proc = subprocess.run(cmd, cwd=project_dir, capture_output=True, text=True)
output = proc.stdout + "\n" + proc.stderr
if proc.returncode not in (0, 1) and "DOC" not in output:
return [], [
f"pydoclint failed (exit {proc.returncode}): {proc.stderr.strip()[:300]}"
]
return list(_parse_pydoclint(output, project_dir)), []
[docs]
def run_lint_level(
package_dir: Path,
*,
project_dir: Path,
style: str = "google",
ignore: Iterable[str] = (),
) -> tuple[list[Finding], list[str]]:
"""Level 0: ruff D plus pydoclint, with notes for anything skipped.
``ignore`` is the ``--ignore`` list every other level applies at file
discovery; the linters walk the package themselves, so their findings are
filtered by the same predicate (:func:`~epythet.validation.docstrings.is_ignored`)
on the file's full path.
"""
from epythet.validation.docstrings import is_ignored
ignore = tuple(ignore)
findings: list[Finding] = []
notes: list[str] = []
for runner in (run_ruff, run_pydoclint):
found, noted = runner(package_dir, project_dir=project_dir, style=style)
findings.extend(
f
for f in found
if not (f.file and is_ignored(project_dir / f.file, ignore))
)
notes.extend(noted)
return findings, notes