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

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 

50 

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

66 

67 

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

76 

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

86 

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) 

94 

95 

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 

109 

110 

111# --- section detection -------------------------------------------------------- 

112 

113 

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 

123 

124 

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 

130 

131 

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 

137 

138 

139# --- helpers ------------------------------------------------------------------ 

140 

141 

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) 

149 

150 

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

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

153 

154 

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

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

157 

158 

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

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

161 

162 

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) 

166 

167 

168# --- per-format extraction ---------------------------------------------------- 

169 

170 

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 

189 

190 

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

197 

198 def flush() -> None: 

199 for one in names: 

200 _store(params, one, fragments) 

201 

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 

227 

228 

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