Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/docformatter/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,59 @@
GOOGLE_REGEX = r"^ *[a-zA-Z0-9_\- ]*:$"
"""Regular expression to use for finding Google-style field lists."""

GOOGLE_SECTION_NAMES = (
"Args",
"Arguments",
"Attributes",
"Example",
"Examples",
"Note",
"Notes",
"Other Parameters",
"Raises",
"Raise",
"Receives",
"Receive",
"References",
"Returns",
"Return",
"See Also",
"Warns",
"Warning",
"Warnings",
"Yields",
"Yield",
)
"""Section headers recognized as Google-style sections."""

GOOGLE_SECTION_REGEX = r"^[ \t]*(" + "|".join(GOOGLE_SECTION_NAMES) + r")[ \t]*:[ \t]*$"
"""Regular expression to use for finding Google-style section headers."""

GOOGLE_WRAPPABLE_SECTIONS = frozenset(
{
"args",
"arguments",
"return",
"returns",
}
)
"""Google section names whose entries should be wrapped independently."""

GOOGLE_ENTRY_REGEX = (
r"^([ \t]*)(\*{0,2}[A-Za-z_][\w]*)[ \t]*(?:\(([^)]*)\))?[ \t]*:" r"[ \t]*(.*)$"
)
"""Regular expression to use for finding Google-style section entries.

Notes
-----
Matches lines such as::

host: Hostname used by the database connection.
stream (BinaryIO): Binary stream (usually a file object).
*args: Variable length argument list.
**kwargs: Arbitrary keyword arguments.
"""

LITERAL_REGEX = r"[\S ]*::"
"""Regular expression to use for finding literal blocks."""

Expand Down
94 changes: 93 additions & 1 deletion src/docformatter/patterns/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@
# Standard Library Imports
import re
from re import Match
from typing import Union
from typing import List, Optional, Tuple, Union

# docformatter Package Imports
from docformatter.constants import (
EPYTEXT_REGEX,
GOOGLE_ENTRY_REGEX,
GOOGLE_REGEX,
GOOGLE_SECTION_REGEX,
NUMPY_REGEX,
SPHINX_REGEX,
)
Expand Down Expand Up @@ -80,6 +82,96 @@ def do_find_field_lists(
return _field_idx, _wrap_parameters


def do_find_google_section_headers(
text: str,
) -> List[Tuple[int, int, str]]:
"""Return Google-style section headers found in the description.

Parameters
----------
text : str
The docstring description to search.

Returns
-------
list[tuple[int, int, str]]
Each tuple is ``(start, end, name)`` for one section header.
``end`` is the first character of the section body.
"""
_headers: List[Tuple[int, int, str]] = []

for _match in re.finditer(
GOOGLE_SECTION_REGEX,
text,
flags=re.IGNORECASE | re.MULTILINE,
):
_body_start = _match.end()
if _body_start < len(text) and text[_body_start] == "\n":
_body_start += 1
_headers.append((_match.start(), _body_start, _match.group(1)))

return _headers


def do_parse_google_entries(
body: str,
) -> List[Tuple[Optional[str], Optional[str], str]]:
"""Parse named or unnamed entries from a Google section body.

Parameters
----------
body : str
Text after the section header, up to the next header or the end.

Returns
-------
list[tuple[str | None, str | None, str]]
Each tuple is ``(name, type_hint, description)``.
``name`` and ``type_hint`` are ``None`` for an unnamed Returns paragraph.
Continuation lines are joined into a single description.
"""
_raw_lines = body.splitlines()
_nonempty = [line for line in _raw_lines if line.strip()]
if not _nonempty:
return []

_entry_pattern = re.compile(GOOGLE_ENTRY_REGEX)
_named_mode = _entry_pattern.match(_nonempty[0]) is not None

if not _named_mode:
_description = " ".join(line.strip() for line in _nonempty)
return [(None, None, _description)] if _description else []

_entries: List[Tuple[Optional[str], Optional[str], str]] = []
_name: Optional[str] = None
_type_hint: Optional[str] = None
_parts: List[str] = []

def _flush() -> None:
if _name is None and not _parts:
return
_description = " ".join(part for part in _parts if part).strip()
_entries.append((_name, _type_hint, _description))

for _line in _raw_lines:
if not _line.strip():
continue

_match = _entry_pattern.match(_line)
if _match:
_flush()
_name = _match.group(2)
_type_hint = _match.group(3)
_rest = _match.group(4).strip()
_parts = [_rest] if _rest else []
continue

_parts.append(_line.strip())

_flush()
return _entries


def is_field_list(
text: str,
style: str,
Expand Down
26 changes: 25 additions & 1 deletion src/docformatter/patterns/lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@
from docformatter.constants import (
BULLET_REGEX,
ENUM_REGEX,
GOOGLE_WRAPPABLE_SECTIONS,
HEURISTIC_MIN_LIST_ASPECT_RATIO,
OPTION_REGEX,
)

# docformatter Local Imports
from .fields import (
do_find_google_section_headers,
is_epytext_field_list,
is_field_list,
is_google_field_list,
Expand Down Expand Up @@ -112,12 +114,34 @@ def is_type_of_list(
"""
split_lines = text.rstrip().splitlines()

if is_heuristic_list(text, strict):
# Google Args/Returns entries look like NumPy "name : description" fields.
# That would skip wrapping of the entire description.
# When a Google section is present, leave wrapping to do_wrap_google_description.
# NumPy style keeps the historical skip behaviour.
_google_headers = do_find_google_section_headers(text)
_has_wrappable_google = style != "numpy" and any(
_name.lower() in GOOGLE_WRAPPABLE_SECTIONS for _, _, _name in _google_headers
)

if not _has_wrappable_google and is_heuristic_list(text, strict):
return True

if is_field_list(text, style):
return False

if _has_wrappable_google:
return any(
(
is_bullet_list(line)
or is_enumerated_list(line)
or is_option_list(line)
or is_literal_block(line)
or is_inline_math(line)
or is_alembic_header(line)
)
for line in split_lines
)

# Check for multi-line patterns (section headers) first.
# These require looking at consecutive lines together.
multiline_windows = _create_multiline_windows(split_lines, window_size=2)
Expand Down
12 changes: 12 additions & 0 deletions src/docformatter/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,18 @@ def do_split_description(
_url_idx,
)

_google_headers = _patterns.do_find_google_section_headers(text)

# Google Args/Returns sections are not Sphinx/Epytext field lists.
# Wrap prose and each entry independently.
# Do not treat the whole description as a list.
if _google_headers and not (_field_idx and _wrap_fields):
return _wrappers.do_wrap_google_description(
text,
indentation,
wrap_length,
)

if not _url_idx and not (_field_idx and _wrap_fields):
return description_to_list(
text,
Expand Down
11 changes: 11 additions & 0 deletions src/docformatter/wrappers/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ def do_wrap_description( # noqa: PLR0913
):
return text

# --force-wrap still unwraps everything as ordinary prose.
# That includes lists and Google sections.
# Structured Args/Returns wrapping is the default path below.
if force_wrap:
return (
indentation
+ "\n".join(
_strings.description_to_list(text, indentation, wrap_length)
).strip()
)

lines = _strings.do_split_description(text, indentation, wrap_length, style)

return indentation + "\n".join(lines).strip()
Expand Down
Loading