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

1"""Parse docstrings: summary, long description, per-parameter docs. 

2 

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. 

7 

8Three conventions are recognised, auto-detected per docstring; whichever 

9appears first wins, no mixing: 

10 

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). 

17 

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""" 

23 

24from __future__ import annotations 

25 

26import inspect 

27import re 

28from dataclasses import dataclass, field 

29 

30__all__ = ["Docstring", "parse"] 

31 

32 

33@dataclass(frozen=True) 

34class Docstring: 

35 """The parsed pieces of one docstring. 

36 

37 Shaped to grow additively (`returns`, `raises`, …) without breaking 

38 reusers; absent pieces are empty, never None. 

39 """ 

40 

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.""" 

52 

53 

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*$") 

74 

75 

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) 

88 

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) 

99 

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) 

107 

108 

109def _opens_with_section(lines: list[str]) -> bool: 

110 """Is the first line a section opener rather than a summary? 

111 

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)) 

125 

126 

127def _reindented(lines: list[str]) -> list[str]: 

128 """Restore the indent `inspect.cleandoc` trims off an opening section. 

129 

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:])] 

141 

142 

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 

156 

157 

158# --- section detection -------------------------------------------------------- 

159 

160 

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 

170 

171 

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 

177 

178 

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 

184 

185 

186# --- helpers ------------------------------------------------------------------ 

187 

188 

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) 

196 

197 

198def _indent(line: str) -> int: 

199 return len(line) - len(line.lstrip()) 

200 

201 

202def _clean_name(name: str) -> str: 

203 return name.lstrip("*").strip() 

204 

205 

206def _join(fragments: list[str]) -> str: 

207 return " ".join(f.strip() for f in fragments if f.strip()).strip() 

208 

209 

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) 

213 

214 

215# --- per-format extraction ---------------------------------------------------- 

216 

217 

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 

250 

251 

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] = [] 

258 

259 def flush() -> None: 

260 for one in names: 

261 _store(params, one, fragments) 

262 

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 

288 

289 

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 

313 

314 

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. 

320 

321 

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 "" 

332 

333 

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 

343 

344 

345def _numpy_returns(lines: list[str]) -> str: 

346 """Prose after a NumPy `Returns` underline, up to the next section. 

347 

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 "" 

368 

369 

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 ""