Skip to content

Commit 6a8a228

Browse files
committed
refactor: Be more strict when parsing sections in Google docstrings
Issue #204: #204
1 parent 773a624 commit 6a8a228

File tree

6 files changed

+162
-121
lines changed

6 files changed

+162
-121
lines changed

docs/docstrings.md

+48-2
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,55 @@ section identifier: optional section title
4646

4747
All sections identifiers are case-insensitive.
4848
All sections support multiple lines in descriptions,
49-
as well as blank lines.
49+
as well as blank lines. The first line must not be blank.
50+
Each section must be separated from contents above by a blank line.
5051

51-
Some sections also support documenting multiple items.
52+
❌ This is **invalid** and will be parsed as regular markup:
53+
54+
```python
55+
Some text.
56+
Note: # (1)!
57+
Some information.
58+
59+
Blank lines allowed.
60+
```
61+
62+
1. Missing blank line above.
63+
64+
❌ This is **invalid** and will be parsed as regular markup:
65+
66+
```python
67+
Some text.
68+
69+
Note: # (1)!
70+
71+
Some information.
72+
73+
Blank lines allowed.
74+
```
75+
76+
1. Extraneous blank line below.
77+
78+
✅ This is **valid** and will parsed as a text section followed by a note admonition:
79+
80+
```python
81+
Some text.
82+
83+
Note:
84+
Some information.
85+
86+
Blank lines allowed.
87+
```
88+
89+
Find out possibly invalid section syntax by grepping for "reasons" in Griffe debug logs:
90+
91+
```bash
92+
griffe dump -Ldebug -o/dev/null \
93+
-fdgoogle -D '{"strict_sections": true}' \
94+
your_package 2>&1 | grep reasons
95+
```
96+
97+
Some sections support documenting multiple items (attributes, parameters, etc.).
5298
When multiple items are supported, each item description can
5399
use multiple lines, and continuation lines must be indented once
54100
more so that the parser is able to differentiate items.

src/griffe/docstrings/google.py

+50-68
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
)
3838
from griffe.docstrings.utils import parse_annotation, warning
3939
from griffe.expressions import ExprName
40+
from griffe.logger import LogLevel
4041

4142
if TYPE_CHECKING:
4243
from typing import Any, Literal, Pattern
@@ -245,12 +246,7 @@ def _read_parameters_section(
245246
**options: Any,
246247
) -> tuple[DocstringSectionParameters | None, int]:
247248
parameters, new_offset = _read_parameters(docstring, offset=offset, **options)
248-
249-
if parameters:
250-
return DocstringSectionParameters(parameters), new_offset
251-
252-
_warn(docstring, new_offset, f"Empty parameters section at line {offset}")
253-
return None, new_offset
249+
return DocstringSectionParameters(parameters), new_offset
254250

255251

256252
def _read_other_parameters_section(
@@ -261,12 +257,7 @@ def _read_other_parameters_section(
261257
**options: Any,
262258
) -> tuple[DocstringSectionOtherParameters | None, int]:
263259
parameters, new_offset = _read_parameters(docstring, offset=offset, warn_unknown_params=False, **options)
264-
265-
if parameters:
266-
return DocstringSectionOtherParameters(parameters), new_offset
267-
268-
_warn(docstring, new_offset, f"Empty other parameters section at line {offset}")
269-
return None, new_offset
260+
return DocstringSectionOtherParameters(parameters), new_offset
270261

271262

272263
def _read_attributes_section(
@@ -302,11 +293,7 @@ def _read_attributes_section(
302293

303294
attributes.append(DocstringAttribute(name=name, annotation=annotation, description=description))
304295

305-
if attributes:
306-
return DocstringSectionAttributes(attributes), new_offset
307-
308-
_warn(docstring, new_offset, f"Empty attributes section at line {offset}")
309-
return None, new_offset
296+
return DocstringSectionAttributes(attributes), new_offset
310297

311298

312299
def _read_functions_section(
@@ -337,11 +324,7 @@ def _read_functions_section(
337324

338325
functions.append(DocstringFunction(name=name, annotation=signature, description=description))
339326

340-
if functions:
341-
return DocstringSectionFunctions(functions), new_offset
342-
343-
_warn(docstring, new_offset, f"Empty functions/methods section at line {offset}")
344-
return None, new_offset
327+
return DocstringSectionFunctions(functions), new_offset
345328

346329

347330
def _read_classes_section(
@@ -372,11 +355,7 @@ def _read_classes_section(
372355

373356
classes.append(DocstringClass(name=name, annotation=signature, description=description))
374357

375-
if classes:
376-
return DocstringSectionClasses(classes), new_offset
377-
378-
_warn(docstring, new_offset, f"Empty classes section at line {offset}")
379-
return None, new_offset
358+
return DocstringSectionClasses(classes), new_offset
380359

381360

382361
def _read_modules_section(
@@ -397,11 +376,7 @@ def _read_modules_section(
397376
description = "\n".join([description.lstrip(), *module_lines[1:]]).rstrip("\n")
398377
modules.append(DocstringModule(name=name, description=description))
399378

400-
if modules:
401-
return DocstringSectionModules(modules), new_offset
402-
403-
_warn(docstring, new_offset, f"Empty modules section at line {offset}")
404-
return None, new_offset
379+
return DocstringSectionModules(modules), new_offset
405380

406381

407382
def _read_raises_section(
@@ -425,11 +400,7 @@ def _read_raises_section(
425400
annotation = parse_annotation(annotation, docstring)
426401
exceptions.append(DocstringRaise(annotation=annotation, description=description))
427402

428-
if exceptions:
429-
return DocstringSectionRaises(exceptions), new_offset
430-
431-
_warn(docstring, new_offset, f"Empty exceptions section at line {offset}")
432-
return None, new_offset
403+
return DocstringSectionRaises(exceptions), new_offset
433404

434405

435406
def _read_warns_section(
@@ -450,11 +421,7 @@ def _read_warns_section(
450421
description = "\n".join([description.lstrip(), *warning_lines[1:]]).rstrip("\n")
451422
warns.append(DocstringWarn(annotation=annotation, description=description))
452423

453-
if warns:
454-
return DocstringSectionWarns(warns), new_offset
455-
456-
_warn(docstring, new_offset, f"Empty warns section at line {offset}")
457-
return None, new_offset
424+
return DocstringSectionWarns(warns), new_offset
458425

459426

460427
def _read_returns_section(
@@ -516,11 +483,7 @@ def _read_returns_section(
516483

517484
returns.append(DocstringReturn(name=name or "", annotation=annotation, description=description))
518485

519-
if returns:
520-
return DocstringSectionReturns(returns), new_offset
521-
522-
_warn(docstring, new_offset, f"Empty returns section at line {offset}")
523-
return None, new_offset
486+
return DocstringSectionReturns(returns), new_offset
524487

525488

526489
def _read_yields_section(
@@ -567,11 +530,7 @@ def _read_yields_section(
567530

568531
yields.append(DocstringYield(name=name or "", annotation=annotation, description=description))
569532

570-
if yields:
571-
return DocstringSectionYields(yields), new_offset
572-
573-
_warn(docstring, new_offset, f"Empty yields section at line {offset}")
574-
return None, new_offset
533+
return DocstringSectionYields(yields), new_offset
575534

576535

577536
def _read_receives_section(
@@ -614,11 +573,7 @@ def _read_receives_section(
614573

615574
receives.append(DocstringReceive(name=name or "", annotation=annotation, description=description))
616575

617-
if receives:
618-
return DocstringSectionReceives(receives), new_offset
619-
620-
_warn(docstring, new_offset, f"Empty receives section at line {offset}")
621-
return None, new_offset
576+
return DocstringSectionReceives(receives), new_offset
622577

623578

624579
def _read_examples_section(
@@ -677,11 +632,7 @@ def _read_examples_section(
677632
elif current_example:
678633
sub_sections.append((DocstringSectionKind.examples, "\n".join(current_example)))
679634

680-
if sub_sections:
681-
return DocstringSectionExamples(sub_sections), new_offset
682-
683-
_warn(docstring, new_offset, f"Empty examples section at line {offset}")
684-
return None, new_offset
635+
return DocstringSectionExamples(sub_sections), new_offset
685636

686637

687638
def _read_deprecated_section(
@@ -692,11 +643,6 @@ def _read_deprecated_section(
692643
) -> tuple[DocstringSectionDeprecated | None, int]:
693644
text, new_offset = _read_block(docstring, offset=offset, **options)
694645

695-
# early exit if there is no text in the yield section
696-
if not text:
697-
_warn(docstring, new_offset, f"Empty deprecated section at line {offset}")
698-
return None, new_offset
699-
700646
# check the presence of a name and description, separated by a semi-colon
701647
try:
702648
version, text = text.split(":", 1)
@@ -733,6 +679,8 @@ def _is_empty_line(line: str) -> bool:
733679
DocstringSectionKind.deprecated: _read_deprecated_section,
734680
}
735681

682+
_sentinel = object()
683+
736684

737685
def parse(
738686
docstring: Docstring,
@@ -800,7 +748,41 @@ def parse(
800748
groups = match.groupdict()
801749
title = groups["title"]
802750
admonition_type = groups["type"]
803-
if admonition_type.lower() in _section_kind:
751+
is_section = admonition_type.lower() in _section_kind
752+
753+
has_previous_line = offset > 0
754+
blank_line_above = not has_previous_line or _is_empty_line(lines[offset - 1])
755+
has_next_line = offset < len(lines) - 1
756+
has_next_lines = offset < len(lines) - 2
757+
blank_line_below = has_next_line and _is_empty_line(lines[offset + 1])
758+
blank_lines_below = has_next_lines and _is_empty_line(lines[offset + 2])
759+
indented_line_below = has_next_line and not blank_line_below and lines[offset + 1].startswith(" ")
760+
indented_lines_below = has_next_lines and not blank_lines_below and lines[offset + 2].startswith(" ")
761+
if not (indented_line_below or indented_lines_below):
762+
# Do not warn when there are no contents,
763+
# this is most probably not a section or admonition.
764+
current_section.append(lines[offset])
765+
offset += 1
766+
continue
767+
reasons = []
768+
kind = "section" if is_section else "admonition"
769+
if (indented_line_below or indented_lines_below) and not blank_line_above:
770+
reasons.append(f"Missing blank line above {kind}")
771+
if indented_lines_below and blank_line_below:
772+
reasons.append(f"Extraneous blank line below {kind} title")
773+
if reasons:
774+
reasons_string = "; ".join(reasons)
775+
_warn(
776+
docstring,
777+
offset,
778+
f"Possible {kind} skipped, reasons: {reasons_string}",
779+
LogLevel.debug,
780+
)
781+
current_section.append(lines[offset])
782+
offset += 1
783+
continue
784+
785+
if is_section:
804786
if current_section:
805787
if any(current_section):
806788
sections.append(DocstringSectionText("\n".join(current_section).rstrip("\n")))

src/griffe/docstrings/utils.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from ast import PyCF_ONLY_AST
66
from contextlib import suppress
7-
from typing import TYPE_CHECKING, Callable
7+
from typing import TYPE_CHECKING, Protocol
88

99
from griffe.agents.nodes import safe_get_annotation
1010
from griffe.logger import LogLevel, get_logger
@@ -14,7 +14,12 @@
1414
from griffe.expressions import Expr
1515

1616

17-
def warning(name: str) -> Callable[[Docstring, int, str], None]:
17+
class WarningCallable(Protocol):
18+
def __call__(self, docstring: Docstring, offset: int, message: str, log_level: LogLevel = ...) -> None:
19+
...
20+
21+
22+
def warning(name: str) -> WarningCallable:
1823
"""Create and return a warn function.
1924
2025
Parameters:
@@ -32,12 +37,13 @@ def warning(name: str) -> Callable[[Docstring, int, str], None]:
3237
"""
3338
logger = get_logger(name)
3439

35-
def warn(docstring: Docstring, offset: int, message: str) -> None:
40+
def warn(docstring: Docstring, offset: int, message: str, log_level: LogLevel = LogLevel.warning) -> None:
3641
try:
3742
prefix = docstring.parent.relative_filepath # type: ignore[union-attr]
3843
except (AttributeError, ValueError):
3944
prefix = "<module>"
40-
logger.warning(f"{prefix}:{(docstring.lineno or 0)+offset}: {message}")
45+
log = getattr(logger, log_level.value)
46+
log(f"{prefix}:{(docstring.lineno or 0)+offset}: {message}")
4147

4248
return warn
4349

tests/conftest.py

-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
11
"""Configuration for the pytest test suite."""
2-
3-
from __future__ import annotations
4-
5-
pytest_plugins = ["griffe.tests"]

tests/test_docstrings/helpers.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from griffe.dataclasses import Attribute, Class, Docstring, Function, Module
88
from griffe.docstrings.dataclasses import DocstringAttribute, DocstringElement, DocstringParameter, DocstringSection
9+
from griffe.logger import LogLevel
910

1011
if TYPE_CHECKING:
1112
from types import ModuleType
@@ -53,7 +54,7 @@ def parse(docstring: str, parent: ParentType | None = None, **parser_opts: Any)
5354
docstring_object.parent = parent
5455
parent.docstring = docstring_object
5556
warnings = []
56-
parser_module._warn = lambda _docstring, _offset, message: warnings.append(message) # type: ignore[attr-defined]
57+
parser_module._warn = lambda _docstring, _offset, message, log_level=LogLevel.warning: warnings.append(message) # type: ignore[attr-defined]
5758
sections = parser_module.parse(docstring_object, **parser_opts)
5859
return sections, warnings
5960

0 commit comments

Comments
 (0)