Coverage for src/footman/docstrings.py: 98%
223 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +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"}`."""
49 returns: str = ""
50 """The `Returns:` prose, joined to one line — what the value *means*,
51 beside whatever schema the return annotation declares."""
54_GOOGLE_HEADER = re.compile(r"^(?:args|arguments|parameters)\s*:\s*$", re.IGNORECASE)
55_NUMPY_HEADER = re.compile(r"^(?:parameters|other parameters)\s*:?\s*$", re.IGNORECASE)
56_UNDERLINE = re.compile(r"^-{3,}\s*$")
57# `name: text`, `name (type): text`, `*args: text` — the colon is required, so
58# a wrapped continuation line almost never false-positives.
59_GOOGLE_ENTRY = re.compile(r"^(\*{0,2}[A-Za-z_]\w*)\s*(?:\([^)]*\))?\s*:\s*(.*)$")
60# `name`, `name : type`, `a, b : int` — names only; the type tail is ignored.
61_NUMPY_ENTRY = re.compile(
62 r"^(\*{0,2}[A-Za-z_]\w*(?:\s*,\s*\*{0,2}[A-Za-z_]\w*)*)(?:\s*:.*)?$"
63)
64_SPHINX_PARAM = re.compile(r"^:(?:param|parameter|arg|argument)\s+([^:]+):\s*(.*)$")
65# `Returns:` alone on its line (Google), `Returns` over an underline (NumPy),
66# `:returns: text` (Sphinx) — the same three conventions the params take.
67_RETURNS_HEADER = re.compile(r"^returns?\s*:\s*$", re.IGNORECASE)
68_RETURNS_NUMPY = re.compile(r"^returns?\s*:?\s*$", re.IGNORECASE)
69_SPHINX_RETURNS = re.compile(r"^:returns?:\s*(.*)$")
70_SPHINX_FIELD = re.compile(r"^:[a-zA-Z]") # any field ends the long description
71# Any Google-style section header (`Returns:`, `See Also:`, …) — alone on its
72# line — ends the long description, whether or not we extract from it.
73_ANY_SECTION = re.compile(r"^[A-Z][A-Za-z ]*:\s*$")
76def parse(text: str | None) -> Docstring:
77 """Parse *text* (a raw or already-cleaned docstring) into its pieces."""
78 if not text:
79 return Docstring()
80 lines = inspect.cleandoc(text).splitlines()
81 if not lines: 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true
82 return Docstring()
83 if _opens_with_section(lines):
84 summary, body = "", _reindented(lines)
85 else:
86 summary, body = lines[0].strip(), lines[1:]
87 returns = _numpy_returns(body) or _google_returns(body) or _sphinx_returns(body)
89 found: list[tuple[int, str]] = []
90 if (i := _find_google(body)) is not None:
91 found.append((i, "google"))
92 if (i := _find_numpy(body)) is not None:
93 found.append((i, "numpy"))
94 if (i := _find_sphinx(body)) is not None:
95 found.append((i, "sphinx"))
96 if not found:
97 long = _prose(body[: _long_end(body, len(body))])
98 return Docstring(summary, long, returns=returns)
100 start, kind = min(found)
101 params = {
102 "google": _google_params,
103 "numpy": _numpy_params,
104 "sphinx": _sphinx_params,
105 }[kind](body, start)
106 return Docstring(summary, _prose(body[: _long_end(body, start)]), params, returns)
109def _opens_with_section(lines: list[str]) -> bool:
110 """Is the first line a section opener rather than a summary?
112 A docstring can start straight at `Args:` (or `:param x:`, or a NumPy
113 underlined header); a lone `Word:` header line is never a one-liner
114 anyone meant as help text, so the summary is empty and the whole text
115 is body."""
116 first = lines[0].strip()
117 if (
118 _ANY_SECTION.match(first)
119 or _GOOGLE_HEADER.match(first)
120 or _SPHINX_FIELD.match(first)
121 ):
122 return True
123 nxt = lines[1].strip() if len(lines) > 1 else ""
124 return bool(_NUMPY_HEADER.match(first) and _UNDERLINE.match(nxt))
127def _reindented(lines: list[str]) -> list[str]:
128 """Restore the indent `inspect.cleandoc` trims off an opening section.
130 As line one, the header is exempt from margin trimming and pins the
131 margin, so the section's body lands flat at the header's own column and
132 the dedent rules would end the section before its first entry. One
133 indent level back gives the walkers the shape the author wrote."""
134 first = lines[0].strip()
135 if not (_GOOGLE_HEADER.match(first) or _RETURNS_HEADER.match(first)):
136 return lines # NumPy and Sphinx openers parse fine flat
137 after = next((ln for ln in lines[1:] if ln.strip()), "")
138 if not after or _indent(after) > 0: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return lines
140 return [lines[0], *((" " + ln) if ln.strip() else ln for ln in lines[1:])]
143def _long_end(body: list[str], stop: int) -> int:
144 """Where the long description ends: the first section-ish line before
145 *stop* — a `Word:` header alone on its line, or a NumPy underlined
146 header — even when it isn't one we extract parameters from."""
147 for i in range(stop):
148 stripped = body[i].strip()
149 if not stripped:
150 continue
151 if _ANY_SECTION.match(stripped):
152 return i
153 if i + 1 < stop and _UNDERLINE.match(body[i + 1].strip()): 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 return i
155 return stop
158# --- section detection --------------------------------------------------------
161def _find_google(lines: list[str]) -> int | None:
162 for i, line in enumerate(lines):
163 if _GOOGLE_HEADER.match(line.strip()):
164 # `Parameters:` over a dash underline is NumPy wearing a colon —
165 # let the NumPy path claim it.
166 nxt = next((s for s in lines[i + 1 :] if s.strip()), "")
167 if not _UNDERLINE.match(nxt.strip()):
168 return i
169 return None
172def _find_numpy(lines: list[str]) -> int | None:
173 for i, line in enumerate(lines[:-1]):
174 if _NUMPY_HEADER.match(line.strip()) and _UNDERLINE.match(lines[i + 1].strip()):
175 return i
176 return None
179def _find_sphinx(lines: list[str]) -> int | None:
180 for i, line in enumerate(lines):
181 if _SPHINX_FIELD.match(line.strip()):
182 return i
183 return None
186# --- helpers ------------------------------------------------------------------
189def _prose(lines: list[str]) -> str:
190 """The long description: outer blank lines dropped, structure kept."""
191 while lines and not lines[0].strip():
192 lines = lines[1:]
193 while lines and not lines[-1].strip():
194 lines = lines[:-1]
195 return "\n".join(line.rstrip() for line in lines)
198def _indent(line: str) -> int:
199 return len(line) - len(line.lstrip())
202def _clean_name(name: str) -> str:
203 return name.lstrip("*").strip()
206def _join(fragments: list[str]) -> str:
207 return " ".join(f.strip() for f in fragments if f.strip()).strip()
210def _store(params: dict[str, str], name: str, fragments: list[str]) -> None:
211 if name and name not in params: # first definition wins on duplicates
212 params[name] = _join(fragments)
215# --- per-format extraction ----------------------------------------------------
218def _google_params(lines: list[str], start: int) -> dict[str, str]:
219 """Entries under a Google `Args:` header, ended by a dedent to it."""
220 header_indent = _indent(lines[start])
221 params: dict[str, str] = {}
222 name = ""
223 fragments: list[str] = []
224 entry_indent: int | None = None
225 prev_indent = header_indent
226 for line in lines[start + 1 :]:
227 if not line.strip():
228 continue # blank lines inside the section are allowed
229 indent = _indent(line)
230 if indent <= header_indent:
231 break # dedent to (or past) the header: the section is over
232 entry = _GOOGLE_ENTRY.match(line.strip())
233 # A line indented deeper than its entry, not dedenting from the
234 # line above, is a continuation even when it happens to read
235 # `Word: text` — `Note: rewrites in place` under an entry documents
236 # that entry, it is not a parameter. (The dedent guard keeps a new
237 # entry at a slightly drifted indent from being swallowed.)
238 deeper = (
239 entry_indent is not None and indent > entry_indent and indent >= prev_indent
240 )
241 if entry and not deeper:
242 _store(params, name, fragments)
243 name, fragments = _clean_name(entry[1]), [entry[2]]
244 entry_indent = indent
245 else:
246 fragments.append(line) # a wrapped continuation of the entry
247 prev_indent = indent
248 _store(params, name, fragments)
249 return params
252def _numpy_params(lines: list[str], start: int) -> dict[str, str]:
253 """`name : type` lines after a NumPy header, descriptions indented."""
254 header_indent = _indent(lines[start])
255 params: dict[str, str] = {}
256 names: list[str] = []
257 fragments: list[str] = []
259 def flush() -> None:
260 for one in names:
261 _store(params, one, fragments)
263 i = start + 2 # skip the header and its underline
264 while i < len(lines):
265 line = lines[i]
266 stripped = line.strip()
267 if not stripped:
268 i += 1
269 continue
270 next_stripped = lines[i + 1].strip() if i + 1 < len(lines) else ""
271 if _indent(line) <= header_indent and _UNDERLINE.match(next_stripped):
272 # A new underlined section: Other Parameters keeps collecting,
273 # anything else (Returns, Notes, …) ends the walk.
274 if _NUMPY_HEADER.match(stripped):
275 i += 2
276 continue
277 break
278 entry = _NUMPY_ENTRY.match(stripped)
279 if entry is not None and _indent(line) <= header_indent:
280 flush()
281 names = [_clean_name(n) for n in entry[1].split(",")]
282 fragments = []
283 else:
284 fragments.append(line) # an indented description line
285 i += 1
286 flush()
287 return params
290def _sphinx_params(lines: list[str], start: int) -> dict[str, str]:
291 """`:param [type] name: text` fields; indented follow-ups continue one."""
292 params: dict[str, str] = {}
293 name = ""
294 fragments: list[str] = []
295 for line in lines[start:]:
296 stripped = line.strip()
297 if not stripped:
298 _store(params, name, fragments)
299 name, fragments = "", []
300 continue
301 if stripped.startswith(":"):
302 _store(params, name, fragments)
303 name, fragments = "", []
304 entry = _SPHINX_PARAM.match(stripped)
305 if entry: # `:param str name:` — the name is the last word
306 name = _clean_name(entry[1].split()[-1])
307 fragments = [entry[2]]
308 continue # any other field (:returns:, :type x:, …) is skipped
309 if name:
310 fragments.append(line)
311 _store(params, name, fragments)
312 return params
315# --- the Returns section ------------------------------------------------------
316# Extracted independently of which params convention won: real docstrings mix
317# (a Google `Args:` above a bare `Returns:` note), and the section is prose
318# either way. NumPy is probed first because its header is the stricter shape
319# (an underline is required), so a Google `Returns:` can never match it.
322def _google_returns(lines: list[str]) -> str:
323 """Prose under a Google `Returns:` header, ended by a dedent to it."""
324 for i, line in enumerate(lines):
325 if _RETURNS_HEADER.match(line.strip()):
326 header_indent = _indent(line)
327 fragments = [
328 ln for ln in _until_dedent(lines[i + 1 :], header_indent) if ln.strip()
329 ]
330 return _join(fragments)
331 return ""
334def _until_dedent(lines: list[str], header_indent: int) -> list[str]:
335 """The lines of one indented section: everything up to the first non-blank
336 line at (or above) the header's indent."""
337 out: list[str] = []
338 for line in lines:
339 if line.strip() and _indent(line) <= header_indent:
340 break
341 out.append(line)
342 return out
345def _numpy_returns(lines: list[str]) -> str:
346 """Prose after a NumPy `Returns` underline, up to the next section.
348 The conventional `type` line and its indented description both join in —
349 "PytestReport The parsed report" reads fine as one line of prose."""
350 for i in range(len(lines) - 1):
351 if not (
352 _RETURNS_NUMPY.match(lines[i].strip())
353 and _UNDERLINE.match(lines[i + 1].strip())
354 ):
355 continue
356 header_indent = _indent(lines[i])
357 fragments: list[str] = []
358 for j in range(i + 2, len(lines)):
359 stripped = lines[j].strip()
360 if not stripped:
361 continue
362 after = lines[j + 1].strip() if j + 1 < len(lines) else ""
363 if _indent(lines[j]) <= header_indent and _UNDERLINE.match(after):
364 break # the next underlined section header
365 fragments.append(lines[j])
366 return _join(fragments)
367 return ""
370def _sphinx_returns(lines: list[str]) -> str:
371 """A `:returns:`/`:return:` field's text, indented follow-ups joined."""
372 for i, line in enumerate(lines):
373 entry = _SPHINX_RETURNS.match(line.strip())
374 if entry is None:
375 continue
376 fragments = [entry[1]]
377 for follow in lines[i + 1 :]:
378 stripped = follow.strip()
379 if not stripped or stripped.startswith(":"):
380 break # a blank line or the next field ends it
381 fragments.append(follow)
382 return _join(fragments)
383 return ""