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
« 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.
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.
7from __future__ import annotations
9from contextlib import suppress
10from dataclasses import dataclass, field
11from inspect import cleandoc
12from typing import TYPE_CHECKING, Any, TypedDict
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
28if TYPE_CHECKING:
29 from collections.abc import Callable
31 from griffe._internal.expressions import Expr
32 from griffe._internal.models import Docstring
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"))
45@dataclass(frozen=True)
46class _FieldType:
47 """Maps directive names to parser functions."""
49 names: frozenset[str]
50 reader: Callable[[Docstring, int, _ParsedValues], int]
52 def matches(self, line: str) -> bool:
53 """Check if a line matches the field type.
55 Parameters:
56 line: Line to check against
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)
64@dataclass
65class _ParsedDirective:
66 """Directive information that has been parsed from a docstring."""
68 line: str
69 next_index: int
70 directive_parts: list[str]
71 value: str
72 invalid: bool = False
75@dataclass
76class _ParsedValues:
77 """Values parsed from the docstring to be used to produce sections."""
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
89class SphinxOptions(TypedDict, total=False):
90 """Options for parsing Sphinx-style docstrings."""
92 warn_unknown_params: bool
93 """Whether to warn about unknown parameters."""
94 warnings: bool
95 """Whether to issue warnings for parsing issues."""
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.
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.
111 Returns:
112 A list of docstring sections.
113 """
114 parsed_values = _ParsedValues()
116 options = {
117 "warn_unknown_params": warn_unknown_params,
118 "warnings": warnings,
119 }
121 lines = docstring.lines
122 curr_line_index = 0
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)
134 curr_line_index += 1
136 return _parsed_values_to_sections(parsed_values)
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
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
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
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)
189 annotation = _determine_param_annotation(docstring, name, directive_type, parsed_values, warnings=warnings)
190 default = _determine_param_default(docstring, name)
192 parsed_values.parameters[name] = DocstringParameter(
193 name=name,
194 annotation=annotation,
195 description=parsed_directive.value,
196 value=default,
197 )
199 return parsed_directive.next_index
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
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
224 parsed_param_type = parsed_values.param_types.get(name)
225 if parsed_param_type is not None:
226 annotation = parsed_param_type
228 if directive_type is not None:
229 annotation = directive_type
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}'")
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}'")
241 return annotation
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)
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
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
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
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
294 annotation: str | Expr | None = None
296 # Annotation precedence:
297 # - "vartype" directive type
298 # - annotation in the parent
299 # - none
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 )
319 return parsed_directive.next_index
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())
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
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
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
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}'")
370 return parsed_directive.next_index
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
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
401 # TODO: maybe support names
402 parsed_values.return_value = DocstringReturn(name="", annotation=annotation, description=parsed_directive.value)
404 return parsed_directive.next_index
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
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
425 return parsed_directive.next_index
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
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)
453 value = value.strip()
454 return _ParsedDirective(line, next_index, directive.split(" "), value)
457def _consolidate_continuation_lines(lines: list[str], offset: int) -> tuple[str, int]:
458 curr_line_index = offset
459 block = [lines[curr_line_index].lstrip()]
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
467 return cleandoc("\n".join(block)).rstrip("\n"), curr_line_index - 1
470def _consolidate_descriptive_type(descriptive_type: str) -> str:
471 return descriptive_type.replace(" or ", " | ")
474def _strip_blank_lines(lines: list[str]) -> list[str]:
475 if not lines:
476 return lines
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]
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]