Coverage for packages / griffelib / src / griffe / _internal / docstrings / sphinx.py: 95.00%

250 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-11 11:48 +0100

1# This module defines functions to parse Sphinx docstrings into structured data. 

2 

3# Credits to Patrick Lannigan ([@plannigan](https://github.com/plannigan)) 

4# who originally added the parser in the [pytkdocs project](https://github.com/mkdocstrings/pytkdocs). 

5# See https://github.com/mkdocstrings/pytkdocs/pull/71. 

6 

7from __future__ import annotations 

8 

9from contextlib import suppress 

10from dataclasses import dataclass, field 

11from inspect import cleandoc 

12from typing import TYPE_CHECKING, Any, TypedDict 

13 

14from griffe._internal.docstrings.models import ( 

15 DocstringAttribute, 

16 DocstringParameter, 

17 DocstringRaise, 

18 DocstringReturn, 

19 DocstringSection, 

20 DocstringSectionAttributes, 

21 DocstringSectionParameters, 

22 DocstringSectionRaises, 

23 DocstringSectionReturns, 

24 DocstringSectionText, 

25) 

26from griffe._internal.docstrings.utils import docstring_warning, parse_docstring_annotation 

27 

28if TYPE_CHECKING: 

29 from collections.abc import Callable 

30 

31 from griffe._internal.expressions import Expr 

32 from griffe._internal.models import Docstring 

33 

34 

35# TODO: Examples: from the documentation, we're not sure there is a standard format for examples 

36_PARAM_NAMES = frozenset(("param", "parameter", "arg", "argument", "key", "keyword")) 

37_PARAM_TYPE_NAMES = frozenset(("type",)) 

38_ATTRIBUTE_NAMES = frozenset(("var", "ivar", "cvar")) 

39_ATTRIBUTE_TYPE_NAMES = frozenset(("vartype",)) 

40_RETURN_NAMES = frozenset(("returns", "return")) 

41_RETURN_TYPE_NAMES = frozenset(("rtype",)) 

42_EXCEPTION_NAMES = frozenset(("raises", "raise", "except", "exception")) 

43 

44 

45@dataclass(frozen=True) 

46class _FieldType: 

47 """Maps directive names to parser functions.""" 

48 

49 names: frozenset[str] 

50 reader: Callable[[Docstring, int, _ParsedValues], int] 

51 

52 def matches(self, line: str) -> bool: 

53 """Check if a line matches the field type. 

54 

55 Parameters: 

56 line: Line to check against 

57 

58 Returns: 

59 True if the line matches the field type, False otherwise. 

60 """ 

61 return any(line.startswith(f":{name}") for name in self.names) 

62 

63 

64@dataclass 

65class _ParsedDirective: 

66 """Directive information that has been parsed from a docstring.""" 

67 

68 line: str 

69 next_index: int 

70 directive_parts: list[str] 

71 value: str 

72 invalid: bool = False 

73 

74 

75@dataclass 

76class _ParsedValues: 

77 """Values parsed from the docstring to be used to produce sections.""" 

78 

79 description: list[str] = field(default_factory=list) 

80 parameters: dict[str, DocstringParameter] = field(default_factory=dict) 

81 param_types: dict[str, str | Expr] = field(default_factory=dict) 

82 attributes: dict[str, DocstringAttribute] = field(default_factory=dict) 

83 attribute_types: dict[str, str] = field(default_factory=dict) 

84 exceptions: list[DocstringRaise] = field(default_factory=list) 

85 return_value: DocstringReturn | None = None 

86 return_type: str | None = None 

87 

88 

89class SphinxOptions(TypedDict, total=False): 

90 """Options for parsing Sphinx-style docstrings.""" 

91 

92 warn_unknown_params: bool 

93 """Whether to warn about unknown parameters.""" 

94 warnings: bool 

95 """Whether to issue warnings for parsing issues.""" 

96 

97 

98def parse_sphinx( 

99 docstring: Docstring, 

100 *, 

101 warn_unknown_params: bool = True, 

102 warnings: bool = True, 

103) -> list[DocstringSection]: 

104 """Parse a Sphinx-style docstring. 

105 

106 Parameters: 

107 docstring: The docstring to parse. 

108 warn_unknown_params: Warn about documented parameters not appearing in the signature. 

109 warnings: Whether to log warnings at all. 

110 

111 Returns: 

112 A list of docstring sections. 

113 """ 

114 parsed_values = _ParsedValues() 

115 

116 options = { 

117 "warn_unknown_params": warn_unknown_params, 

118 "warnings": warnings, 

119 } 

120 

121 lines = docstring.lines 

122 curr_line_index = 0 

123 

124 while curr_line_index < len(lines): 

125 line = lines[curr_line_index] 

126 for field_type in _field_types: 

127 if field_type.matches(line): 

128 # https://github.com/python/mypy/issues/5485 

129 curr_line_index = field_type.reader(docstring, curr_line_index, parsed_values, **options) 

130 break 

131 else: 

132 parsed_values.description.append(line) 

133 

134 curr_line_index += 1 

135 

136 return _parsed_values_to_sections(parsed_values) 

137 

138 

139def _read_parameter( 

140 docstring: Docstring, 

141 offset: int, 

142 parsed_values: _ParsedValues, 

143 *, 

144 warn_unknown_params: bool = True, 

145 warnings: bool = True, 

146 **options: Any, # noqa: ARG001 

147) -> int: 

148 parsed_directive = _parse_directive(docstring, offset, warnings=warnings) 

149 if parsed_directive.invalid: 

150 return parsed_directive.next_index 

151 

152 directive_type = None 

153 if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004 

154 # no type info 

155 name = parsed_directive.directive_parts[1] 

156 elif len(parsed_directive.directive_parts) == 3: # noqa: PLR2004 

157 directive_type = parse_docstring_annotation( 

158 parsed_directive.directive_parts[1], 

159 docstring, 

160 ) 

161 name = parsed_directive.directive_parts[2] 

162 elif len(parsed_directive.directive_parts) > 3: # noqa: PLR2004 

163 # Ignoring type info, only a type with a single word is valid 

164 # https://www.sphinx-doc.org/en/master/usage/domains/python.html#info-field-lists 

165 name = parsed_directive.directive_parts[-1] 

166 if warnings: 166 ↛ 173line 166 didn't jump to line 173 because the condition on line 166 was always true

167 docstring_warning(docstring, 0, f"Failed to parse field directive from '{parsed_directive.line}'") 

168 else: 

169 if warnings: 169 ↛ 171line 169 didn't jump to line 171 because the condition on line 169 was always true

170 docstring_warning(docstring, 0, f"Failed to parse field directive from '{parsed_directive.line}'") 

171 return parsed_directive.next_index 

172 

173 if name in parsed_values.parameters: 

174 if warnings: 174 ↛ 176line 174 didn't jump to line 176 because the condition on line 174 was always true

175 docstring_warning(docstring, 0, f"Duplicate parameter entry for '{name}'") 

176 return parsed_directive.next_index 

177 

178 if warnings and warn_unknown_params: 

179 with suppress(AttributeError): # For Parameters sections in objects without parameters. 

180 params = docstring.parent.parameters # ty:ignore[unresolved-attribute] 

181 if name not in params: 

182 message = f"Parameter '{name}' does not appear in the function signature" 

183 for starred_name in (f"*{name}", f"**{name}"): 

184 if starred_name in params: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 message += f". Did you mean '{starred_name}'?" 

186 break 

187 docstring_warning(docstring, 0, message) 

188 

189 annotation = _determine_param_annotation(docstring, name, directive_type, parsed_values, warnings=warnings) 

190 default = _determine_param_default(docstring, name) 

191 

192 parsed_values.parameters[name] = DocstringParameter( 

193 name=name, 

194 annotation=annotation, 

195 description=parsed_directive.value, 

196 value=default, 

197 ) 

198 

199 return parsed_directive.next_index 

200 

201 

202def _determine_param_default(docstring: Docstring, name: str) -> str | None: 

203 try: 

204 return docstring.parent.parameters[name.lstrip()].default # ty:ignore[unresolved-attribute] 

205 except (AttributeError, KeyError): 

206 return None 

207 

208 

209def _determine_param_annotation( 

210 docstring: Docstring, 

211 name: str, 

212 directive_type: str | Expr | None, 

213 parsed_values: _ParsedValues, 

214 *, 

215 warnings: bool = True, 

216) -> Any: 

217 # Annotation precedence: 

218 # - in-line directive type 

219 # - "type" directive type 

220 # - signature annotation 

221 # - none 

222 annotation: str | Expr | None = None 

223 

224 parsed_param_type = parsed_values.param_types.get(name) 

225 if parsed_param_type is not None: 

226 annotation = parsed_param_type 

227 

228 if directive_type is not None: 

229 annotation = directive_type 

230 

231 if warnings and directive_type is not None and parsed_param_type is not None: 

232 docstring_warning(docstring, 0, f"Duplicate parameter information for '{name}'") 

233 

234 if annotation is None: 

235 try: 

236 annotation = docstring.parent.parameters[name.lstrip()].annotation # ty:ignore[unresolved-attribute] 

237 except (AttributeError, KeyError): 

238 if warnings: 

239 docstring_warning(docstring, 0, f"No matching parameter for '{name}'") 

240 

241 return annotation 

242 

243 

244def _read_parameter_type( 

245 docstring: Docstring, 

246 offset: int, 

247 parsed_values: _ParsedValues, 

248 *, 

249 warnings: bool = True, 

250 **options: Any, # noqa: ARG001 

251) -> int: 

252 parsed_directive = _parse_directive(docstring, offset, warnings=warnings) 

253 if parsed_directive.invalid: 

254 return parsed_directive.next_index 

255 param_type_str = _consolidate_descriptive_type(parsed_directive.value.strip()) 

256 param_type = parse_docstring_annotation(param_type_str, docstring) 

257 

258 if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004 

259 param_name = parsed_directive.directive_parts[1] 

260 else: 

261 if warnings: 261 ↛ 263line 261 didn't jump to line 263 because the condition on line 261 was always true

262 docstring_warning(docstring, 0, f"Failed to get parameter name from '{parsed_directive.line}'") 

263 return parsed_directive.next_index 

264 

265 parsed_values.param_types[param_name] = param_type 

266 param = parsed_values.parameters.get(param_name) 

267 if param is not None: 

268 if param.annotation is None: 

269 param.annotation = param_type 

270 else: 

271 docstring_warning(docstring, 0, f"Duplicate parameter information for '{param_name}'") 

272 return parsed_directive.next_index 

273 

274 

275def _read_attribute( 

276 docstring: Docstring, 

277 offset: int, 

278 parsed_values: _ParsedValues, 

279 *, 

280 warnings: bool = True, 

281 **options: Any, # noqa: ARG001 

282) -> int: 

283 parsed_directive = _parse_directive(docstring, offset, warnings=warnings) 

284 if parsed_directive.invalid: 

285 return parsed_directive.next_index 

286 

287 if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004 

288 name = parsed_directive.directive_parts[1] 

289 else: 

290 if warnings: 290 ↛ 292line 290 didn't jump to line 292 because the condition on line 290 was always true

291 docstring_warning(docstring, 0, f"Failed to parse field directive from '{parsed_directive.line}'") 

292 return parsed_directive.next_index 

293 

294 annotation: str | Expr | None = None 

295 

296 # Annotation precedence: 

297 # - "vartype" directive type 

298 # - annotation in the parent 

299 # - none 

300 

301 parsed_attribute_type = parsed_values.attribute_types.get(name) 

302 if parsed_attribute_type is not None: 

303 annotation = parsed_attribute_type 

304 else: 

305 # try to use the annotation from the parent 

306 with suppress(AttributeError, KeyError, TypeError): 

307 # Use subscript syntax to fetch annotation from inherited members too. 

308 annotation = docstring.parent[name].annotation # ty:ignore[not-subscriptable] 

309 if name in parsed_values.attributes: 

310 if warnings: 310 ↛ 319line 310 didn't jump to line 319 because the condition on line 310 was always true

311 docstring_warning(docstring, 0, f"Duplicate attribute entry for '{name}'") 

312 else: 

313 parsed_values.attributes[name] = DocstringAttribute( 

314 name=name, 

315 annotation=annotation, 

316 description=parsed_directive.value, 

317 ) 

318 

319 return parsed_directive.next_index 

320 

321 

322def _read_attribute_type( 

323 docstring: Docstring, 

324 offset: int, 

325 parsed_values: _ParsedValues, 

326 *, 

327 warnings: bool = True, 

328 **options: Any, # noqa: ARG001 

329) -> int: 

330 parsed_directive = _parse_directive(docstring, offset, warnings=warnings) 

331 if parsed_directive.invalid: 

332 return parsed_directive.next_index 

333 attribute_type = _consolidate_descriptive_type(parsed_directive.value.strip()) 

334 

335 if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004 

336 attribute_name = parsed_directive.directive_parts[1] 

337 else: 

338 if warnings: 338 ↛ 340line 338 didn't jump to line 340 because the condition on line 338 was always true

339 docstring_warning(docstring, 0, f"Failed to get attribute name from '{parsed_directive.line}'") 

340 return parsed_directive.next_index 

341 

342 parsed_values.attribute_types[attribute_name] = attribute_type 

343 attribute = parsed_values.attributes.get(attribute_name) 

344 if attribute is not None: 

345 if attribute.annotation is None: 345 ↛ 347line 345 didn't jump to line 347 because the condition on line 345 was always true

346 attribute.annotation = attribute_type 

347 elif warnings: 

348 docstring_warning(docstring, 0, f"Duplicate attribute information for '{attribute_name}'") 

349 return parsed_directive.next_index 

350 

351 

352def _read_exception( 

353 docstring: Docstring, 

354 offset: int, 

355 parsed_values: _ParsedValues, 

356 *, 

357 warnings: bool = True, 

358 **options: Any, # noqa: ARG001 

359) -> int: 

360 parsed_directive = _parse_directive(docstring, offset, warnings=warnings) 

361 if parsed_directive.invalid: 

362 return parsed_directive.next_index 

363 

364 if len(parsed_directive.directive_parts) == 2: # noqa: PLR2004 

365 ex_type = parsed_directive.directive_parts[1] 

366 parsed_values.exceptions.append(DocstringRaise(annotation=ex_type, description=parsed_directive.value)) 

367 elif warnings: 367 ↛ 370line 367 didn't jump to line 370 because the condition on line 367 was always true

368 docstring_warning(docstring, 0, f"Failed to parse exception directive from '{parsed_directive.line}'") 

369 

370 return parsed_directive.next_index 

371 

372 

373def _read_return( 

374 docstring: Docstring, 

375 offset: int, 

376 parsed_values: _ParsedValues, 

377 *, 

378 warn_missing_types: bool = True, 

379 warnings: bool = True, 

380 **options: Any, # noqa: ARG001 

381) -> int: 

382 parsed_directive = _parse_directive(docstring, offset, warnings=warnings) 

383 if parsed_directive.invalid: 

384 return parsed_directive.next_index 

385 

386 # Annotation precedence: 

387 # - "rtype" directive type 

388 # - signature annotation 

389 # - None 

390 annotation: str | Expr | None 

391 if parsed_values.return_type is not None: 

392 annotation = parsed_values.return_type 

393 else: 

394 try: 

395 annotation = docstring.parent.annotation # ty:ignore[unresolved-attribute] 

396 except AttributeError: 

397 if warnings and warn_missing_types: 397 ↛ 399line 397 didn't jump to line 399 because the condition on line 397 was always true

398 docstring_warning(docstring, 0, f"No return type or annotation at '{parsed_directive.line}'") 

399 annotation = None 

400 

401 # TODO: maybe support names 

402 parsed_values.return_value = DocstringReturn(name="", annotation=annotation, description=parsed_directive.value) 

403 

404 return parsed_directive.next_index 

405 

406 

407def _read_return_type( 

408 docstring: Docstring, 

409 offset: int, 

410 parsed_values: _ParsedValues, 

411 *, 

412 warnings: bool = True, 

413 **options: Any, # noqa: ARG001 

414) -> int: 

415 parsed_directive = _parse_directive(docstring, offset, warnings=warnings) 

416 if parsed_directive.invalid: 

417 return parsed_directive.next_index 

418 

419 return_type = _consolidate_descriptive_type(parsed_directive.value.strip()) 

420 parsed_values.return_type = return_type 

421 return_value = parsed_values.return_value 

422 if return_value is not None: 

423 return_value.annotation = return_type 

424 

425 return parsed_directive.next_index 

426 

427 

428def _parsed_values_to_sections(parsed_values: _ParsedValues) -> list[DocstringSection]: 

429 text = "\n".join(_strip_blank_lines(parsed_values.description)) 

430 result: list[DocstringSection] = [DocstringSectionText(text)] 

431 if parsed_values.parameters: 

432 param_values = list(parsed_values.parameters.values()) 

433 result.append(DocstringSectionParameters(param_values)) 

434 if parsed_values.attributes: 

435 attribute_values = list(parsed_values.attributes.values()) 

436 result.append(DocstringSectionAttributes(attribute_values)) 

437 if parsed_values.return_value is not None: 

438 result.append(DocstringSectionReturns([parsed_values.return_value])) 

439 if parsed_values.exceptions: 

440 result.append(DocstringSectionRaises(parsed_values.exceptions)) 

441 return result 

442 

443 

444def _parse_directive(docstring: Docstring, offset: int, *, warnings: bool = True) -> _ParsedDirective: 

445 line, next_index = _consolidate_continuation_lines(docstring.lines, offset) 

446 try: 

447 _, directive, value = line.split(":", 2) 

448 except ValueError: 

449 if warnings: 449 ↛ 451line 449 didn't jump to line 451 because the condition on line 449 was always true

450 docstring_warning(docstring, 0, f"Failed to get ':directive: value' pair from '{line}'") 

451 return _ParsedDirective(line, next_index, [], "", invalid=True) 

452 

453 value = value.strip() 

454 return _ParsedDirective(line, next_index, directive.split(" "), value) 

455 

456 

457def _consolidate_continuation_lines(lines: list[str], offset: int) -> tuple[str, int]: 

458 curr_line_index = offset 

459 block = [lines[curr_line_index].lstrip()] 

460 

461 # start processing after first item 

462 curr_line_index += 1 

463 while curr_line_index < len(lines) and not lines[curr_line_index].startswith(":"): 

464 block.append(lines[curr_line_index]) 

465 curr_line_index += 1 

466 

467 return cleandoc("\n".join(block)).rstrip("\n"), curr_line_index - 1 

468 

469 

470def _consolidate_descriptive_type(descriptive_type: str) -> str: 

471 return descriptive_type.replace(" or ", " | ") 

472 

473 

474def _strip_blank_lines(lines: list[str]) -> list[str]: 

475 if not lines: 

476 return lines 

477 

478 # remove blank lines from the start and end 

479 content_found = False 

480 initial_content = 0 

481 final_content = 0 

482 for index, line in enumerate(lines): 

483 if not line or line.isspace(): 

484 if not content_found: 

485 initial_content += 1 

486 else: 

487 content_found = True 

488 final_content = index 

489 return lines[initial_content : final_content + 1] 

490 

491 

492_field_types = [ 

493 _FieldType(_PARAM_TYPE_NAMES, _read_parameter_type), 

494 _FieldType(_PARAM_NAMES, _read_parameter), 

495 _FieldType(_ATTRIBUTE_TYPE_NAMES, _read_attribute_type), 

496 _FieldType(_ATTRIBUTE_NAMES, _read_attribute), 

497 _FieldType(_EXCEPTION_NAMES, _read_exception), 

498 _FieldType(_RETURN_NAMES, _read_return), 

499 _FieldType(_RETURN_TYPE_NAMES, _read_return_type), 

500]