iru-python-docstrings
Audit given Python file(s)/package scope (FastAPI service, Django project, or plain library) for complete, well-formed PEP 257 docstrings on every public module, package, class, function, method and p
SKILL.md.claude/skills/iru-python-docstrings/SKILL.md
GitHubPython Docstrings
Make sure a set of Python modules carry complete, well-formed docstrings, filling in whatever is missing, then prove
the result is valid through Ruff's docstring rules and the project's own API-docs build. This skill only adds/completes
docstrings (and, when the project lists modules explicitly, the API-docs entry that makes a new public module render)
— it does not change behavior, signatures, type hints, decorators, imports, or any non-docstring code. Test code
(tests/, test_*.py, *_test.py, conftest.py) is out of scope — it doesn't need docstrings added, even if named
explicitly — but any docstring a test already has must be left as-is. It makes no assumptions about this being any
particular repository — discover the project's flavor, conventions, and tooling fresh each run.
Step 0 — Detect the project's flavor and runner
Flavor, first match wins (the same markers iru-explore uses): a root manage.py or django in the project's
dependencies → django; fastapi in the dependencies → fastapi; anything else with a pyproject.toml/
setup.cfg/setup.py → library. The flavor decides what's excluded (Step 1) and the API-facing caveats (Step 2).
Runner — detect, never install globally, never pip install into the system Python:
| Marker | Prefix |
|---|---|
uv.lock |
uv run (add --group <g> for a dependency group that isn't synced by default, e.g. docs) |
poetry.lock |
poetry run |
pdm.lock |
pdm run |
Pipfile.lock |
pipenv run |
.venv/ |
.venv/bin/<tool> |
| none | python3 -m <tool> — warn that no project environment was found |
Ruff not in the project's environment (<runner> ruff --version fails) → use uvx ruff when uv exists and say so;
otherwise report that the Ruff check couldn't run. Editing docstrings needs no environment; only Step 5 does.
Step 1 — Determine scope
- Argument provided (comma-separated paths, globs, or dotted module names): resolve each to its
.pyfile(s) viafind/ls(a dotted name →src/<a>/<b>.py,<a>/<b>.py, or<a>/<b>/__init__.py). Drop any resolved test file per the definition above — say so in the Step 6 report rather than silently ignoring it. - No argument: default to every non-test
.pyfile touched by uncommitted changes plus commits on the current branch not yet on the base branch, same approach asiru-update-docs:
(determine the base branch viagit status git diff <base-branch>...HEAD --name-only git diff --name-onlygit symbolic-ref refs/remotes/origin/HEADorgit branch -aif unclear; ask the user only if genuinely ambiguous). - Always filter out virtualenvs (
.venv/,venv/),build/,dist/,node_modules/, generated code (a setuptools-scm/hatch-vcs_version.py,*_pb2.py, anything git-ignored), Djangomigrations/(generated bymakemigrations; never edited by hand, documentation included),manage.py, and Alembicversions/revisions. - If the resulting scope is empty, tell the user there is nothing to document and stop.
- What gets documented (the audit set for Step 3), following PEP 257 and Ruff's own notion of "public": every
public module (no leading underscore in the module path) and package
__init__.py; every public class, function, method (including@property,@classmethod,@staticmethod,@overload-free implementations) and nested public class; module-level public constants and class attributes, documented through the owning module/class docstring (Attributes:) or an attribute docstring when the project uses those. A module's__all__, when present, is the authoritative public surface — a name outside it is not a gap._privatenames,__dunder__magic methods and__init__are out of scope unless the project's Ruff config enablesD105/D107(with the Google convention the constructor's arguments go in the class docstring'sArgs:, which is what napoleon renders by default).
Step 2 — Discover this project's actual docstring bar before auditing
Don't assume a fixed convention — discover it from the project itself:
- Convention:
[tool.ruff.lint.pydocstyle] convention(or[lint.pydocstyle]inruff.toml/.ruff.toml), else[tool.pydocstyle]/setup.cfg [pydocstyle] convention, else[tool.numpydoc_validation](numpy), else Sphinxconf.pynapoleon flags (napoleon_numpy_docstring = Truewithnapoleon_google_docstring = False→ numpy) or mkdocstringsdocstring_style:, else observed practice, else Google — this catalog's default and the scaffold contract's (iru-setup-python-*writesconvention = "google"). A reST/Sphinx-field project (:param x:/:returns:) stays reST; never convert an existing style. - Ruff selection: whether
Dis already inselect/extend-select, what's ignored (D105,D107,D203…), andper-file-ignores(tests and migrations usually ignoreD). The project's ignores are part of the bar. - interrogate:
[tool.interrogate]inpyproject.toml(or.interrogate,setup.cfg [tool:interrogate]) —fail-under,ignore-init-method,ignore-init-module,ignore-magic,ignore-private(double-underscore names only),ignore-semiprivate(single underscore),ignore-property-decorators,ignore-nested-functions. - API docs tool (Step 5 builds whichever exists): Sphinx —
docs/api/conf.py(this catalog's scaffold contract, next to Antora'sdocs/antora.yml),docs/conf.py,docs/source/conf.py,docs/sphinx/conf.py; MkDocs —mkdocs.ymlwith amkdocstringsplugin; pdoc — apdocdependency or a script/CI step calling it. Note whether modules are listed explicitly (.. automodule::,::: pkg.mod) or discovered (autosummary :recursive:,sphinx-apidoc,sphinx-autoapi, a gen-files script). - Contributor guides (
CLAUDE.md,AGENTS.md,CONTRIBUTING,README) and 2-3 well-documented modules near the scope: voice (imperative "Return …" vs third person "Returns …" — Google convention doesn't enforceD401), whetherRaises:andExamples:(doctest>>>) are habitual, line length (72 chars for docstrings per PEP 8 is a recommendation; Ruff'sE501/W505decide), and attribute style. Follow the stricter of stated vs observed.
The convention, Google flavor (adapt the section syntax for numpy — underlined Parameters/Returns/Raises,
name : type — or reST fields):
- Summary: one line, ending in a period, on the first line of the docstring (
D212under Google), saying what the object does or represents; a blank line, then an optional description. Module docstrings say what the module holds; package__init__.pydocstrings describe the package. Args:— one entry per parameter (name: Description.),*args/**kwargsincluded; no types when the signature has type hints (sphinx-autodoc-typehints, mkdocstrings and pdoc render them from the annotations — repeating them drifts). Say what aNonedefault means, units, accepted values.Returns:/Yields:— what is returned, not its type again; omitted for-> None.Raises:— oneExceptionType: When …entry per exception the body raises or deliberately lets propagate (griffe/mkdocstrings rejects an entry that isn'tException: description— verified).- Classes: summary, then
Attributes:for public instance/class attributes (dataclass/Pydantic/attrs fields included) andArgs:for constructor parameters that aren't attributes. Properties: document the getter only, as an attribute ("The shelf's capacity."). Examples:with>>>lines only where the project already runs doctests or the API is non-obvious.@deprecated/warnings.warn(DeprecationWarning)→ a.. deprecated:: X.Ynote (Sphinx) carried over verbatim, never added on a guess.
Framework caveats — docstrings here are also API documentation, so write them for the API's consumers:
- FastAPI: a path-operation function's docstring becomes the OpenAPI operation
description(Markdown, shown in Swagger UI/ReDoc) unless the decorator passesdescription=; FastAPI truncates it at the first form feed. Put the consumer-facing text first, then\f, then the developer-onlyArgs:/Returns:/Raises:sections (verified: only the text before\freachesapp.openapi()). A non-raw docstring containing\ftrips RuffD301— add# noqa: D301after the docstring's closing quotes (verified; making the string raw would turn\finto two literal characters and break the truncation). Pydantic model docstrings become the JSON-schemadescriptionof the component (verified,Attributes:section included) — keep them consumer-facing too; withmodel_config = ConfigDict(use_attribute_docstrings=True)an attribute docstring under a field becomes that field'sdescription(verified), otherwise useField(description=…), which is code and out of scope here. ADependsdependency function's docstring is not published. - Django: models, managers, querysets, services, forms and template tags are documented like any library code;
Meta/Mediainner classes are not. DRF views/viewsets — drf-spectacular takes the view docstring as the operation description unless@extend_schema(description=…)overrides it; Django Ninja does the same with the operation function's docstring — so keep developer-only sections out of those or move them to a comment. Admin classes,apps.pyAppConfigs and settings modules need only a module/class summary.
Step 3 — Audit each in-scope file
Start with Ruff for the mechanical part, scoped to the files, with the docstring rules forced on:
<runner> ruff check --select D [--config 'lint.pydocstyle.convention = "google"'] --output-format json <files> > build/iru-python-docstrings-ruff.json
Pass --config …convention… only when the project sets no convention (then use the one Step 2 resolved) — with no
convention Ruff enables mutually incompatible rules (D203/D211, D212/D213). --select D replaces the
project's selection for this run but keeps its per-file-ignores/ignore. Read the JSON with python3 -c and keep
code, filename, location.row, message per finding — never paste the raw file. D100–D107 are missing
docstrings (module, class, method, function, package, magic, nested class, __init__), D417 a parameter missing
from Args:, the rest formatting. Write the report under build/ (create it if needed; remove build/ at the end
only if this run created it) and delete it afterwards.
Then read each full file — Ruff doesn't know whether a docstring is any good — and, for every symbol in Step 1's audit set, check that the docstring exists, actually describes the symbol rather than restating its name, and is current:
- Modules/packages: a docstring saying what the module provides.
- Functions/methods: summary;
Args:entry for every parameter, in signature order, none for removed/renamed parameters;Returns:/Yields:when a value is returned/yielded;Raises:for each exception that can propagate. - Classes: summary;
Attributes:for public fields; constructorArgs:per the project's habit. - FastAPI routes / DRF views / Ninja operations / Pydantic models: consumer-facing first part, per Step 2.
For each gap record: the symbol, its kind, what's missing (whole docstring vs a missing/stale section or entry), and the file.
Step 4 — Generate the missing docstrings
Before editing, if Step 5's tools are wired, run the API-docs build (and interrogate) once and keep its warnings per file as this run's baseline — Step 5 needs it to tell pre-existing warnings from ones the new docstrings caused.
For every gap from Step 3, write the docstring grounded in:
- The actual implementation — read the body: what is returned and when (
None, empty list), which exceptions are raised or re-raised, which fields a model exposes, what a route returns per status code, which side effects happen (commits, emails,transaction.on_commithooks). Never claim a guarantee the code doesn't give. - Current uncommitted/branch changes (
git diff/git log -pfor that file) — describe current behavior, not what the name suggests. - The convention from Step 2 — section names, voice, indentation, wrapping — so new docstrings are indistinguishable from hand-written ones.
Apply the edits with the Edit tool, as the first statement of the module/class/function (a module docstring goes above the imports, after a license header comment or shebang/encoding line). Do not modify code, signatures, type hints, decorators, or formatting outside the added/completed docstrings, and don't reorder members.
API-docs reachability: when the project lists modules explicitly (Step 2) and an in-scope public module is new and
listed nowhere, add the entry that makes it render — an .. automodule:: <pkg.mod> with :members: in
docs/api/index.rst (or the project's per-module .rst layout), or a ::: <pkg.mod> line on the matching MkDocs
page. Discovered layouts (autosummary :recursive:, autoapi, apidoc in CI) need nothing. Say in the report which
entries were added.
Step 5 — Verify
Run what the project has, scoped where the tool allows it, through Step 0's runner. Write every output under build/
with an iru-python-docstrings- prefix and delete it afterwards.
- Ruff
D— re-run Step 3's command. Every finding in an in-scope file must be gone (aDfinding in a symbol this run didn't touch but that sits in an in-scope file is still a gap — fix it). Then the advisory pydoclint rules, which catch whatDcan't:<runner> ruff check --preview --select DOC [--config '…convention…'] --output-format json <files>DOC201(return not documented),DOC402(yield not documented),DOC501(raised exception missing fromRaises:),DOC502(documented exception never raised). These are preview rules (Ruff 0.17) — fix the ones in docstrings you wrote, report the rest as advisory, and note that--output-format conciseprints rule names instead of codes for preview rules, which is why JSON is used. They only check functions whose docstring has sections; a one-line summary docstring isn't flagged. - interrogate (only when configured — Step 2):
<runner> interrogate -c pyproject.toml -vv <paths>(oruvx interrogate …when it isn't a project dependency). Exit 1 means coverage is under the project'sfail-under; the-vvtable marks each symbolCOVERED/MISSED— everyMISSEDin an in-scope file must be fixed or explained (e.g. a single-underscore method interrogate counts because the project didn't setignore-semiprivate). - API docs build, warnings as errors, into a throwaway target so the project's real output is untouched:
- Sphinx (
<sphinx-dir>= the directory holdingconf.pyfrom Step 2;docs/apiper the scaffold contract):
(other runners:uv run --group docs sphinx-build -W --keep-going -b html <sphinx-dir> build/iru-python-docstrings-site<runner> sphinx-build …; use the dependency group/extra the project declares Sphinx in — the contract's isdocs). Exit 1 = at least one warning. Warnings name their source as<path>.py:docstring of <dotted.name>:<line>: WARNING: …— the line is relative to the docstring. Typical docstring-caused ones:Unexpected indentation,Block quote ends without a blank line,Bullet list ends without a blank line,Inline emphasis start-string without end-string(an unescaped*args— write*argsor\*argsin reST, though napoleon handles*argsinsideArgs:),undefined label/py:class reference target not foundwith-n, andduplicate object description of <x>— a dataclass/Pydantic field documented both in the class'sAttributes:section and by autodoc's own member listing (verified; fixed by the project settingnapoleon_use_ivar = True, or by:no-index:/excluding members — aconf.pychange: report it, don't make it). A warning about a module that fails to import (autodoc: failed to import) is an environment/ config problem — for Django theconf.pymust setDJANGO_SETTINGS_MODULEand calldjango.setup()before autodoc imports models; report it as pre-existing rather than editingconf.py. - MkDocs + mkdocstrings:
<runner> mkdocs build --strict -d build/iru-python-docstrings-site(from the directory holdingmkdocs.yml;-f <path>otherwise). griffe's docstring parser warnings (griffe: <file>:<line>: Failed to get 'exception: description' pair …,No type or annotation for parameter …,Parameter … does not appear in the function signature) abort a strict build — fix those in in-scope files. - pdoc:
<runner> pdoc -d <google|numpy|restructuredtext> -o build/iru-python-docstrings-site <in-scope modules>naming the in-scope modules explicitly — pdoc exits non-zero with a traceback when a named module fails to import, but when walking a package it skips broken submodules silently, and it never validates docstring syntax (it renders whatever it gets), so Ruff is the real check on a pdoc project. - None of the three configured → say so: no API-docs build exists, so only Ruff (and interrogate) verified the
docstrings. Don't add a docs toolchain — that is
iru-setup-python-*'s job. Compare against the Step 4 baseline: any new warning naming an in-scope file was caused by this run — fix and re-run until none remain. Pre-existing warnings are not this skill's to fix.
- Sphinx (
- FastAPI (only when a route/model docstring was touched and the project has the export script from the scaffold
contract):
uv run python scripts/export_openapi.py build/iru-python-docstrings-openapi.json, then confirm the touched operations'descriptions contain noArgs:/Returns:text.
A failure naming a symbol or file outside the current scope is pre-existing — mention it in the report as a follow-up, don't fix it unless the user asks.
Step 6 — Report
Per file in scope: how many symbols were already fully documented, how many gaps were found and filled (name each,
with its kind and what was added — e.g. "Shelf class: summary + Attributes: (2)", "add(): Raises: ValueError",
"list_books route: consumer description + \f + Args:"), and any API-docs entry added (Step 4). Then the
verification result per tool that ran — Ruff D: clean or the remaining codes; Ruff DOC (advisory): remaining
codes; interrogate: coverage % vs fail-under; Sphinx/MkDocs/pdoc: pass, or the warnings still open — and which
tools weren't available or configured. Name the convention used and where it came from, any argument dropped as a
test file or migration, and out-of-scope pre-existing failures as follow-ups. Never paste raw tool output or JSON.
Do not fix lint or type issues unrelated to docstrings, change code to make a docstring "true", edit conf.py/
mkdocs.yml/Ruff config, edit license headers, or add/modify tests — those belong to iru-python-code-quality, the
coding skills, and the user.
Verification notes
Verified in $TMPDIR/iru-verify/python/docs-bump/demo/ (a uv init --lib project, uv 0.12.8, CPython 3.13, Ruff
0.17.0, Sphinx 9.1.0 with sphinx.ext.autodoc + sphinx.ext.napoleon + sphinx_autodoc_typehints in a docs dependency
group, docs/api/conf.py + index.rst per the scaffold contract):
ruff check --select D --config 'lint.pydocstyle.convention = "google"' --output-format json srcon undocumented code reportedD100/D101/D102/D105withcode/filename/location.row/message; a partialArgs:gaveD417; with[tool.ruff.lint.per-file-ignores] "tests/**" = ["S101", "D"]the tests stayed clean under--select D; switching the override tonumpyflagged Google sections (D406/D407).--preview --select DOCreportedDOC501(missingRaises: ValueError) andDOC201; without--previewRuff warns the selection has no effect. A summary-only docstring wasn't flagged byDOC201.uv run --group docs sphinx-build -W --keep-going -b html docs/api build/iru-python-docstrings-site: exit 1 with…/shelf.py:docstring of demo_lib.shelf.titles_by:17: WARNING: Bullet list ends without a blank line; unexpected unindent. [docutils]; exit 1 withduplicate object description of demo_lib.shelf.Shelf.capacityfor a dataclass with anAttributes:section (cleared bynapoleon_use_ivar = True); exit 0 once fixed. A mis-indented continuation line insideArgs:produced no Sphinx warning (napoleon is lenient) — Ruff/griffe are the stricter checks.- FastAPI 0.143.1: a route docstring with
\f→app.openapi()description held only the text before it; Ruff reportedD301, silenced by""" # noqa: D301on the closing line; Sphinx rendered theArgs:after\fcleanly. Pydantic 2.14.1: model docstring (withAttributes:) became the schemadescription;use_attribute_docstrings=Trueturned a field's attribute docstring into itsdescription(needs the class in a real source file). uvx interrogate -c pyproject.toml -vv src(interrogate 1.7.0): exit 1 belowfail-under = 100, listingShelf._internal … MISSEDbecauseignore-privateonly covers double-underscore names.mkdocs build --strictwithmkdocstrings[python](docstring_style: google): aborted ongriffe: …/shelf.py:50: Failed to get 'exception: description' pair …for a free-textRaises:entry that Sphinx and Ruff accepted; exit 0 after the fix.- pdoc 16.0.0 (
-d google -o …): exit 0 rendering the package; naming a module that fails to import → exit 1 with a traceback; the same module reached only by walking the package was skipped silently.
Not verified: Sphinx autodoc on a real Django project (django.setup() in conf.py), drf-spectacular/Ninja
description extraction, sphinx-autoapi/autosummary-recursive layouts, numpy/reST conventions end-to-end through
Sphinx, and poetry run/pdm run/pipenv run runners.
Sources
- irurueta docs: programming-languages/python/docstrings.adoc (PEP 257 layout; Google/NumPy/reST styles; Sphinx autodoc + napoleon, pdoc, MkDocs + mkdocstrings)
- irurueta docs: programming-languages/python/lexical-structure-and-style.adoc (PEP 8, 72-char docstring lines)
- irurueta docs: programming-languages/python/debugging-and-tooling.adoc (Ruff as the default linter)
- irurueta docs: backend/fastapi/openapi-and-interactive-docs.adoc (docstring → operation description,
\ftruncation) - irurueta docs: web/django/django-rest-framework.adoc, web/django/django-ninja.adoc (schema generation from views)
- PEP 257; Ruff rules
D(pydocstyle) andDOC(pydoclint); Sphinx napoleon; mkdocstrings-python/griffe; pdoc