Coverage for src/footman/docstrings.py: 98%
149 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Parse docstrings: summary, long description, per-parameter docs.
3Standalone by design — stdlib only, no footman imports — so the module can be
4lifted into any project that wants structured docstrings. footman uses it to
5fill parameter help (an explicit `doc("...")` marker wins) and the long
6description `fm --help <task>` shows.
8Three conventions are recognised, auto-detected per docstring; whichever
9appears first wins, no mixing:
11- **Google** — `Args:` / `Arguments:` / `Parameters:` header, `name: text`
12 or `name (type): text` entries, continuations indented deeper;
13- **NumPy** — `Parameters` over a `----` underline, `name : type` entry
14 lines, descriptions indented beneath (`a, b : int` documents both);
15- **Sphinx** — `:param name: text` / `:param type name: text` fields
16 (`:arg`/`:argument`/`:parameter` accepted).
18The parser is a single pass over cleaned lines (`inspect.cleandoc`, so tabs
19and uniform indentation are normalised first) and is deliberately tolerant of
20the real world: uneven indents, a missing blank line after the summary, CRLF,
21sections in unusual orders, and empty entries all parse rather than raise.
22"""
24from __future__ import annotations
26import inspect
27import re
28from dataclasses import dataclass, field
30__all__ = ["Docstring", "parse"]
33@dataclass(frozen=True)
34class Docstring:
35 """The parsed pieces of one docstring.
37 Shaped to grow additively (`returns`, `raises`, …) without breaking
38 reusers; absent pieces are empty, never None.
39 """
41 summary: str = ""
42 """The first line — the one-liner listings and completion menus show."""
43 long: str = ""
44 """Prose between the summary and the first section (`Args:` and
45 friends), structure preserved; empty when there is none."""
46 params: dict[str, str] = field(default_factory=dict)
47 """Per-parameter help, keyed by the Python parameter name (not the
48 CLI spelling): `{"fix": "apply safe fixes in place"}`."""
51_GOOGLE_HEADER = re.compile(r"^(?:args|arguments|parameters)\s*:\s*$", re.IGNORECASE)
52_NUMPY_HEADER = re.compile(r"^(?:parameters|other parameters)\s*:?\s*$", re.IGNORECASE)
53_UNDERLINE = re.compile(r"^-{3,}\s*$")
54# `name: text`, `name (type): text`, `*args: text` — the colon is required, so
55# a wrapped continuation line almost never false-positives.
56_GOOGLE_ENTRY = re.compile(r"^(\*{0,2}[A-Za-z_]\w*)\s*(?:\([^)]*\))?\s*:\s*(.*)$")
57# `name`, `name : type`, `a, b : int` — names only; the type tail is ignored.
58_NUMPY_ENTRY = re.compile(
59 r"^(\*{0,2}[A-Za-z_]\w*(?:\s*,\s*\*{0,2}[A-Za-z_]\w*)*)(?:\s*:.*)?$"
60)
61_SPHINX_PARAM = re.compile(r"^:(?:param|parameter|arg|argument)\s+([^:]+):\s*(.*)$")
62_SPHINX_FIELD = re.compile(r"^:[a-zA-Z]") # any field ends the long description
63# Any Google-style section header (`Returns:`, `See Also:`, …) — alone on its
64# line — ends the long description, whether or not we extract from it.
65_ANY_SECTION = re.compile(r"^[A-Z][A-Za-z ]*:\s*$")
68def parse(text: str | None) -> Docstring:
69 """Parse *text* (a raw or already-cleaned docstring) into its pieces."""
70 if not text:
71 return Docstring()
72 lines = inspect.cleandoc(text).splitlines()
73 if not lines: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 return Docstring()
75 summary, body = lines[0].strip(), lines[1:]
77 found: list[tuple[int, str]] = []
78 if (i := _find_google(body)) is not None:
79 found.append((i, "google"))
80 if (i := _find_numpy(body)) is not None:
81 found.append((i, "numpy"))
82 if (i := _find_sphinx(body)) is not None:
83 found.append((i, "sphinx"))
84 if not found:
85 return Docstring(summary, _prose(body[: _long_end(body, len(body))]))
87 start, kind = min(found)
88 params = {
89 "google": _google_params,
90 "numpy": _numpy_params,
91 "sphinx": _sphinx_params,
92 }[kind](body, start)
93 return Docstring(summary, _prose(body[: _long_end(body, start)]), params)
96def _long_end(body: list[str], stop: int) -> int:
97 """Where the long description ends: the first section-ish line before
98 *stop* — a `Word:` header alone on its line, or a NumPy underlined
99 header — even when it isn't one we extract parameters from."""
100 for i in range(stop):
101 stripped = body[i].strip()
102 if not stripped:
103 continue
104 if _ANY_SECTION.match(stripped):
105 return i
106 if i + 1 < stop and _UNDERLINE.match(body[i + 1].strip()): 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true
107 return i
108 return stop
111# --- section detection --------------------------------------------------------
114def _find_google(lines: list[str]) -> int | None:
115 for i, line in enumerate(lines):
116 if _GOOGLE_HEADER.match(line.strip()):
117 # `Parameters:` over a dash underline is NumPy wearing a colon —
118 # let the NumPy path claim it.
119 nxt = next((s for s in lines[i + 1 :] if s.strip()), "")
120 if not _UNDERLINE.match(nxt.strip()):
121 return i
122 return None
125def _find_numpy(lines: list[str]) -> int | None:
126 for i, line in enumerate(lines[:-1]):
127 if _NUMPY_HEADER.match(line.strip()) and _UNDERLINE.match(lines[i + 1].strip()):
128 return i
129 return None
132def _find_sphinx(lines: list[str]) -> int | None:
133 for i, line in enumerate(lines):
134 if _SPHINX_FIELD.match(line.strip()):
135 return i
136 return None
139# --- helpers ------------------------------------------------------------------
142def _prose(lines: list[str]) -> str:
143 """The long description: outer blank lines dropped, structure kept."""
144 while lines and not lines[0].strip():
145 lines = lines[1:]
146 while lines and not lines[-1].strip():
147 lines = lines[:-1]
148 return "\n".join(line.rstrip() for line in lines)
151def _indent(line: str) -> int:
152 return len(line) - len(line.lstrip())
155def _clean_name(name: str) -> str:
156 return name.lstrip("*").strip()
159def _join(fragments: list[str]) -> str:
160 return " ".join(f.strip() for f in fragments if f.strip()).strip()
163def _store(params: dict[str, str], name: str, fragments: list[str]) -> None:
164 if name and name not in params: # first definition wins on duplicates
165 params[name] = _join(fragments)
168# --- per-format extraction ----------------------------------------------------
171def _google_params(lines: list[str], start: int) -> dict[str, str]:
172 """Entries under a Google `Args:` header, ended by a dedent to it."""
173 header_indent = _indent(lines[start])
174 params: dict[str, str] = {}
175 name, fragments = "", []
176 for line in lines[start + 1 :]:
177 if not line.strip():
178 continue # blank lines inside the section are allowed
179 if _indent(line) <= header_indent:
180 break # dedent to (or past) the header: the section is over
181 entry = _GOOGLE_ENTRY.match(line.strip())
182 if entry:
183 _store(params, name, fragments)
184 name, fragments = _clean_name(entry[1]), [entry[2]]
185 else:
186 fragments.append(line) # a wrapped continuation of the entry
187 _store(params, name, fragments)
188 return params
191def _numpy_params(lines: list[str], start: int) -> dict[str, str]:
192 """`name : type` lines after a NumPy header, descriptions indented."""
193 header_indent = _indent(lines[start])
194 params: dict[str, str] = {}
195 names: list[str] = []
196 fragments: list[str] = []
198 def flush() -> None:
199 for one in names:
200 _store(params, one, fragments)
202 i = start + 2 # skip the header and its underline
203 while i < len(lines):
204 line = lines[i]
205 stripped = line.strip()
206 if not stripped:
207 i += 1
208 continue
209 next_stripped = lines[i + 1].strip() if i + 1 < len(lines) else ""
210 if _indent(line) <= header_indent and _UNDERLINE.match(next_stripped):
211 # A new underlined section: Other Parameters keeps collecting,
212 # anything else (Returns, Notes, …) ends the walk.
213 if _NUMPY_HEADER.match(stripped):
214 i += 2
215 continue
216 break
217 entry = _NUMPY_ENTRY.match(stripped)
218 if entry is not None and _indent(line) <= header_indent:
219 flush()
220 names = [_clean_name(n) for n in entry[1].split(",")]
221 fragments = []
222 else:
223 fragments.append(line) # an indented description line
224 i += 1
225 flush()
226 return params
229def _sphinx_params(lines: list[str], start: int) -> dict[str, str]:
230 """`:param [type] name: text` fields; indented follow-ups continue one."""
231 params: dict[str, str] = {}
232 name, fragments = "", []
233 for line in lines[start:]:
234 stripped = line.strip()
235 if not stripped:
236 _store(params, name, fragments)
237 name, fragments = "", []
238 continue
239 if stripped.startswith(":"):
240 _store(params, name, fragments)
241 name, fragments = "", []
242 entry = _SPHINX_PARAM.match(stripped)
243 if entry: # `:param str name:` — the name is the last word
244 name = _clean_name(entry[1].split()[-1])
245 fragments = [entry[2]]
246 continue # any other field (:returns:, :type x:, …) is skipped
247 if name:
248 fragments.append(line)
249 _store(params, name, fragments)
250 return params