Coverage for src/pytkdocs/parsers/docstrings/google.py: 92.35%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""This module defines functions and classes to parse docstrings into structured data."""
2import inspect
3import re
4from typing import Any, List, Optional, Pattern, Tuple
6from pytkdocs.parsers.docstrings.base import AnnotatedObject, Attribute, Parameter, Parser, Section, empty
8SECTIONS_TITLES = {
9 "args:": Section.Type.PARAMETERS,
10 "arguments:": Section.Type.PARAMETERS,
11 "params:": Section.Type.PARAMETERS,
12 "parameters:": Section.Type.PARAMETERS,
13 "keyword args:": Section.Type.KEYWORD_ARGS,
14 "keyword arguments:": Section.Type.KEYWORD_ARGS,
15 "raise:": Section.Type.EXCEPTIONS,
16 "raises:": Section.Type.EXCEPTIONS,
17 "except:": Section.Type.EXCEPTIONS,
18 "exceptions:": Section.Type.EXCEPTIONS,
19 "return:": Section.Type.RETURN,
20 "returns:": Section.Type.RETURN,
21 "yield:": Section.Type.YIELD,
22 "yields:": Section.Type.YIELD,
23 "example:": Section.Type.EXAMPLES,
24 "examples:": Section.Type.EXAMPLES,
25 "attribute:": Section.Type.ATTRIBUTES,
26 "attributes:": Section.Type.ATTRIBUTES,
27}
29RE_GOOGLE_STYLE_ADMONITION: Pattern = re.compile(r"^(?P<indent>\s*)(?P<type>[\w-]+):((?:\s+)(?P<title>.+))?$")
30"""Regular expressions to match lines starting admonitions, of the form `TYPE: [TITLE]`."""
31RE_DOCTEST_BLANKLINE: Pattern = re.compile(r"^\s*<BLANKLINE>\s*$")
32"""Regular expression to match lines of the form `<BLANKLINE>`."""
33RE_DOCTEST_FLAGS: Pattern = re.compile(r"(\s*#\s*doctest:.+)$")
34"""Regular expression to match lines containing doctest flags of the form `# doctest: +FLAG`."""
37class Google(Parser):
38 """A Google-style docstrings parser."""
40 def __init__(self, replace_admonitions: bool = True, trim_doctest_flags: bool = True) -> None:
41 """
42 Initialize the object.
44 Arguments:
45 replace_admonitions: Whether to replace admonitions by their Markdown equivalent.
46 trim_doctest_flags: Whether to remove doctest flags.
47 """
48 super().__init__()
49 self.replace_admonitions = replace_admonitions
50 self.trim_doctest_flags = trim_doctest_flags
51 self.section_reader = {
52 Section.Type.PARAMETERS: self.read_parameters_section,
53 Section.Type.KEYWORD_ARGS: self.read_keyword_arguments_section,
54 Section.Type.EXCEPTIONS: self.read_exceptions_section,
55 Section.Type.EXAMPLES: self.read_examples_section,
56 Section.Type.ATTRIBUTES: self.read_attributes_section,
57 Section.Type.RETURN: self.read_return_section,
58 Section.Type.YIELD: self.read_yield_section,
59 }
61 def parse_sections(self, docstring: str) -> List[Section]: # noqa: D102
62 if "signature" not in self.context:
63 self.context["signature"] = getattr(self.context["obj"], "signature", None)
64 if "annotation" not in self.context: 64 ↛ 66line 64 didn't jump to line 66, because the condition on line 64 was never false
65 self.context["annotation"] = getattr(self.context["obj"], "type", empty)
66 if "attributes" not in self.context:
67 self.context["attributes"] = {}
69 sections = []
70 current_section = []
72 in_code_block = False
74 lines = docstring.split("\n")
75 i = 0
77 while i < len(lines):
78 line_lower = lines[i].lower()
80 if in_code_block:
81 if line_lower.lstrip(" ").startswith("```"):
82 in_code_block = False
83 current_section.append(lines[i])
85 elif line_lower in SECTIONS_TITLES:
86 if current_section:
87 if any(current_section): 87 ↛ 89line 87 didn't jump to line 89, because the condition on line 87 was never false
88 sections.append(Section(Section.Type.MARKDOWN, "\n".join(current_section)))
89 current_section = []
90 section_reader = self.section_reader[SECTIONS_TITLES[line_lower]]
91 section, i = section_reader(lines, i + 1)
92 if section:
93 sections.append(section)
95 elif line_lower.lstrip(" ").startswith("```"):
96 in_code_block = True
97 current_section.append(lines[i])
99 else:
100 if self.replace_admonitions and not in_code_block and i + 1 < len(lines):
101 match = RE_GOOGLE_STYLE_ADMONITION.match(lines[i])
102 if match:
103 groups = match.groupdict()
104 indent = groups["indent"]
105 if lines[i + 1].startswith(indent + " " * 4):
106 lines[i] = f"{indent}!!! {groups['type'].lower()}"
107 if groups["title"]:
108 lines[i] += f' "{groups["title"]}"'
109 current_section.append(lines[i])
111 i += 1
113 if current_section:
114 sections.append(Section(Section.Type.MARKDOWN, "\n".join(current_section)))
116 return sections
118 def read_block_items(self, lines: List[str], start_index: int) -> Tuple[List[str], int]:
119 """
120 Parse an indented block as a list of items.
122 The first indentation level is used as a reference to determine if the next lines are new items
123 or continuation lines.
125 Arguments:
126 lines: The block lines.
127 start_index: The line number to start at.
129 Returns:
130 A tuple containing the list of concatenated lines and the index at which to continue parsing.
131 """
132 if start_index >= len(lines): 132 ↛ 133line 132 didn't jump to line 133, because the condition on line 132 was never true
133 return [], start_index
135 i = start_index
136 items: List[str] = []
138 # skip first empty lines
139 while is_empty_line(lines[i]):
140 i += 1
142 # get initial indent
143 indent = len(lines[i]) - len(lines[i].lstrip())
145 if indent == 0:
146 # first non-empty line was not indented, abort
147 return [], i - 1
149 # start processing first item
150 current_item = [lines[i][indent:]]
151 i += 1
153 # loop on next lines
154 while i < len(lines):
155 line = lines[i]
157 if line.startswith(indent * 2 * " "):
158 # continuation line
159 current_item.append(line[indent * 2 :])
161 elif line.startswith((indent + 1) * " "):
162 # indent between initial and continuation: append but add error
163 cont_indent = len(line) - len(line.lstrip())
164 current_item.append(line[cont_indent:])
165 self.error(
166 f"Confusing indentation for continuation line {i+1} in docstring, "
167 f"should be {indent} * 2 = {indent*2} spaces, not {cont_indent}"
168 )
170 elif line.startswith(indent * " "):
171 # indent equal to initial one: new item
172 items.append("\n".join(current_item))
173 current_item = [line[indent:]]
175 elif is_empty_line(line):
176 # empty line: preserve it in the current item
177 current_item.append("")
179 else:
180 # indent lower than initial one: end of section
181 break
183 i += 1
185 if current_item: 185 ↛ 188line 185 didn't jump to line 188, because the condition on line 185 was never false
186 items.append("\n".join(current_item).rstrip("\n"))
188 return items, i - 1
190 def read_block(self, lines: List[str], start_index: int) -> Tuple[str, int]:
191 """
192 Parse an indented block.
194 Arguments:
195 lines: The block lines.
196 start_index: The line number to start at.
198 Returns:
199 A tuple containing the list of lines and the index at which to continue parsing.
200 """
201 if start_index >= len(lines): 201 ↛ 202line 201 didn't jump to line 202, because the condition on line 201 was never true
202 return "", start_index
204 i = start_index
205 block: List[str] = []
207 # skip first empty lines
208 while is_empty_line(lines[i]):
209 i += 1
211 # get initial indent
212 indent = len(lines[i]) - len(lines[i].lstrip())
214 if indent == 0:
215 # first non-empty line was not indented, abort
216 return "", i - 1
218 # start processing first item
219 block.append(lines[i].lstrip())
220 i += 1
222 # loop on next lines
223 while i < len(lines) and (lines[i].startswith(indent * " ") or is_empty_line(lines[i])):
224 block.append(lines[i][indent:])
225 i += 1
227 return "\n".join(block).rstrip("\n"), i - 1
229 def _parse_parameters_section(self, lines: List[str], start_index: int) -> Tuple[List[Parameter], int]:
230 """
231 Parse a "parameters" or "keyword args" section.
233 Arguments:
234 lines: The parameters block lines.
235 start_index: The line number to start at.
237 Returns:
238 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
239 """
240 parameters = []
241 type_: Any
242 block, i = self.read_block_items(lines, start_index)
244 for param_line in block:
246 # Check that there is an annotation in the docstring
247 try:
248 name_with_type, description = param_line.split(":", 1)
249 except ValueError:
250 self.error(f"Failed to get 'name: description' pair from '{param_line}'")
251 continue
253 # Setting defaults
254 default = empty
255 annotation = empty
256 kind = None
257 # Can only get description from docstring - keep if no type was given
258 description = description.lstrip()
260 # If we have managed to find a type in the docstring use this
261 if " " in name_with_type:
262 name, type_ = name_with_type.split(" ", 1)
263 annotation = type_.strip("()")
264 if annotation.endswith(", optional"): # type: ignore
265 annotation = annotation[:-10] # type: ignore
266 # Otherwise try to use the signature as `annotation` would still be empty
267 else:
268 name = name_with_type
270 # Check in the signature to get extra details
271 try:
272 signature_param = self.context["signature"].parameters[name.lstrip("*")]
273 except (AttributeError, KeyError):
274 if annotation is empty:
275 self.error(f"No type annotation for parameter '{name}'")
276 else:
277 if annotation is empty:
278 annotation = signature_param.annotation
279 # If signature_param.X are empty it doesnt matter as defaults are empty anyway
280 default = signature_param.default
281 kind = signature_param.kind
283 parameters.append(
284 Parameter(name=name, annotation=annotation, description=description, default=default, kind=kind)
285 )
287 return parameters, i
289 def read_parameters_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
290 """
291 Parse a "parameters" section.
293 Arguments:
294 lines: The parameters block lines.
295 start_index: The line number to start at.
297 Returns:
298 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
299 """
300 parameters, i = self._parse_parameters_section(lines, start_index)
302 if parameters:
303 return Section(Section.Type.PARAMETERS, parameters), i
305 self.error(f"Empty parameters section at line {start_index}")
306 return None, i
308 def read_keyword_arguments_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
309 """
310 Parse a "keyword arguments" section.
312 Arguments:
313 lines: The parameters block lines.
314 start_index: The line number to start at.
316 Returns:
317 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
318 """
319 parameters, i = self._parse_parameters_section(lines, start_index)
320 for parameter in parameters:
321 parameter.kind = inspect.Parameter.KEYWORD_ONLY
323 if parameters:
324 return Section(Section.Type.KEYWORD_ARGS, parameters), i
326 self.error(f"Empty keyword arguments section at line {start_index}")
327 return None, i
329 def read_attributes_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
330 """
331 Parse an "attributes" section.
333 Arguments:
334 lines: The parameters block lines.
335 start_index: The line number to start at.
337 Returns:
338 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
339 """
340 attributes = []
341 block, i = self.read_block_items(lines, start_index)
343 for attr_line in block:
344 try:
345 name_with_type, description = attr_line.split(":", 1)
346 except ValueError:
347 self.error(f"Failed to get 'name: description' pair from '{attr_line}'")
348 continue
350 description = description.lstrip()
352 if " " in name_with_type:
353 name, annotation = name_with_type.split(" ", 1)
354 annotation = annotation.strip("()")
355 if annotation.endswith(", optional"): 355 ↛ 356line 355 didn't jump to line 356, because the condition on line 355 was never true
356 annotation = annotation[:-10]
357 else:
358 name = name_with_type
359 annotation = self.context["attributes"].get(name, {}).get("annotation", empty)
361 attributes.append(Attribute(name=name, annotation=annotation, description=description))
363 if attributes: 363 ↛ 366line 363 didn't jump to line 366, because the condition on line 363 was never false
364 return Section(Section.Type.ATTRIBUTES, attributes), i
366 self.error(f"Empty attributes section at line {start_index}")
367 return None, i
369 def read_exceptions_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
370 """
371 Parse an "exceptions" section.
373 Arguments:
374 lines: The exceptions block lines.
375 start_index: The line number to start at.
377 Returns:
378 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
379 """
380 exceptions = []
381 block, i = self.read_block_items(lines, start_index)
383 for exception_line in block:
384 try:
385 annotation, description = exception_line.split(": ", 1)
386 except ValueError:
387 self.error(f"Failed to get 'exception: description' pair from '{exception_line}'")
388 else:
389 exceptions.append(AnnotatedObject(annotation, description.lstrip(" ")))
391 if exceptions:
392 return Section(Section.Type.EXCEPTIONS, exceptions), i
394 self.error(f"Empty exceptions section at line {start_index}")
395 return None, i
397 def read_return_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
398 """
399 Parse an "returns" section.
401 Arguments:
402 lines: The return block lines.
403 start_index: The line number to start at.
405 Returns:
406 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
407 """
408 text, i = self.read_block(lines, start_index)
410 # Early exit if there is no text in the return section
411 if not text:
412 self.error(f"Empty return section at line {start_index}")
413 return None, i
415 # First try to get the annotation and description from the docstring
416 try:
417 type_, text = text.split(":", 1)
418 except ValueError:
419 description = text
420 annotation = self.context["annotation"]
421 # If there was no annotation in the docstring then move to signature
422 if annotation is empty and self.context["signature"]:
423 annotation = self.context["signature"].return_annotation
424 else:
425 annotation = type_.lstrip()
426 description = text.lstrip()
428 # There was no type in the docstring and no annotation
429 if annotation is empty:
430 self.error("No return type/annotation in docstring/signature")
432 return Section(Section.Type.RETURN, AnnotatedObject(annotation, description)), i
434 def read_yield_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
435 """
436 Parse a "yields" section.
438 Arguments:
439 lines: The return block lines.
440 start_index: The line number to start at.
442 Returns:
443 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
444 """
445 text, i = self.read_block(lines, start_index)
447 # Early exit if there is no text in the yield section
448 if not text: 448 ↛ 449line 448 didn't jump to line 449, because the condition on line 448 was never true
449 self.error(f"Empty yield section at line {start_index}")
450 return None, i
452 # First try to get the annotation and description from the docstring
453 try:
454 type_, text = text.split(":", 1)
455 except ValueError:
456 description = text
457 annotation = self.context["annotation"]
458 # If there was no annotation in the docstring then move to signature
459 if annotation is empty and self.context["signature"]: 459 ↛ 466line 459 didn't jump to line 466, because the condition on line 459 was never false
460 annotation = self.context["signature"].return_annotation
461 else:
462 annotation = type_.lstrip()
463 description = text.lstrip()
465 # There was no type in the docstring and no annotation
466 if annotation is empty: 466 ↛ 467line 466 didn't jump to line 467, because the condition on line 466 was never true
467 self.error("No yield type/annotation in docstring/signature")
469 return Section(Section.Type.YIELD, AnnotatedObject(annotation, description)), i
471 def read_examples_section(self, lines: List[str], start_index: int) -> Tuple[Optional[Section], int]:
472 """
473 Parse an "examples" section.
475 Arguments:
476 lines: The examples block lines.
477 start_index: The line number to start at.
479 Returns:
480 A tuple containing a `Section` (or `None`) and the index at which to continue parsing.
481 """
482 text, i = self.read_block(lines, start_index)
484 sub_sections = []
485 in_code_example = False
486 in_code_block = False
487 current_text: List[str] = []
488 current_example: List[str] = []
490 for line in text.split("\n"):
491 if is_empty_line(line):
492 if in_code_example:
493 if current_example: 493 ↛ 496line 493 didn't jump to line 496, because the condition on line 493 was never false
494 sub_sections.append((Section.Type.EXAMPLES, "\n".join(current_example)))
495 current_example = []
496 in_code_example = False
497 else:
498 current_text.append(line)
500 elif in_code_example:
501 if self.trim_doctest_flags:
502 line = RE_DOCTEST_FLAGS.sub("", line)
503 line = RE_DOCTEST_BLANKLINE.sub("", line)
504 current_example.append(line)
506 elif line.startswith("```"):
507 in_code_block = not in_code_block
508 current_text.append(line)
510 elif in_code_block:
511 current_text.append(line)
513 elif line.startswith(">>>"):
514 if current_text:
515 sub_sections.append((Section.Type.MARKDOWN, "\n".join(current_text)))
516 current_text = []
517 in_code_example = True
519 if self.trim_doctest_flags:
520 line = RE_DOCTEST_FLAGS.sub("", line)
521 current_example.append(line)
523 else:
524 current_text.append(line)
526 if current_text: 526 ↛ 527line 526 didn't jump to line 527, because the condition on line 526 was never true
527 sub_sections.append((Section.Type.MARKDOWN, "\n".join(current_text)))
528 elif current_example: 528 ↛ 531line 528 didn't jump to line 531, because the condition on line 528 was never false
529 sub_sections.append((Section.Type.EXAMPLES, "\n".join(current_example)))
531 if sub_sections: 531 ↛ 534line 531 didn't jump to line 534, because the condition on line 531 was never false
532 return Section(Section.Type.EXAMPLES, sub_sections), i
534 self.error(f"Empty examples section at line {start_index}")
535 return None, i
538def is_empty_line(line) -> bool:
539 """
540 Tell if a line is empty.
542 Arguments:
543 line: The line to check.
545 Returns:
546 True if the line is empty or composed of blanks only, False otherwise.
547 """
548 return not line.strip()