diff --git a/src/docformatter/constants.py b/src/docformatter/constants.py index d95e16d..489eadb 100644 --- a/src/docformatter/constants.py +++ b/src/docformatter/constants.py @@ -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.""" diff --git a/src/docformatter/patterns/fields.py b/src/docformatter/patterns/fields.py index 307ad44..be99438 100644 --- a/src/docformatter/patterns/fields.py +++ b/src/docformatter/patterns/fields.py @@ -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, ) @@ -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, diff --git a/src/docformatter/patterns/lists.py b/src/docformatter/patterns/lists.py index a3579cd..f5c6ffc 100644 --- a/src/docformatter/patterns/lists.py +++ b/src/docformatter/patterns/lists.py @@ -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, @@ -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) diff --git a/src/docformatter/strings.py b/src/docformatter/strings.py index c306afe..75a247e 100644 --- a/src/docformatter/strings.py +++ b/src/docformatter/strings.py @@ -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, diff --git a/src/docformatter/wrappers/description.py b/src/docformatter/wrappers/description.py index 18d8767..a6d2cd6 100644 --- a/src/docformatter/wrappers/description.py +++ b/src/docformatter/wrappers/description.py @@ -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() diff --git a/src/docformatter/wrappers/fields.py b/src/docformatter/wrappers/fields.py index 291cf6d..a003f9d 100644 --- a/src/docformatter/wrappers/fields.py +++ b/src/docformatter/wrappers/fields.py @@ -29,11 +29,12 @@ # Standard Library Imports import re import textwrap -from typing import List, Tuple +from typing import List, Optional, Tuple # docformatter Package Imports +import docformatter.patterns as _patterns import docformatter.strings as _strings -from docformatter.constants import DEFAULT_INDENT +from docformatter.constants import DEFAULT_INDENT, GOOGLE_WRAPPABLE_SECTIONS def do_wrap_field_lists( # noqa: PLR0913 @@ -171,3 +172,133 @@ def _do_wrap_field(field_name, field_body, indentation, wrap_length): _wrapped_field[_idx] = f"{_indent}{re.sub(' +', ' ', _field.strip())}" return _wrapped_field + + +def do_wrap_google_description( + text: str, + indentation: str, + wrap_length: int, +) -> List[str]: + """Wrap prose and Google-style Args/Returns entries independently. + + Parameters + ---------- + text : str + The description text, already reindented to ``indentation``. + indentation : str + The indentation to place in front of each description line. + wrap_length : int + The column at which to wrap long lines. + + Returns + ------- + list[str] + The wrapped description lines, each including ``indentation``. + """ + _headers = _patterns.do_find_google_section_headers(text) + if not _headers: + return _strings.description_to_list(text, indentation, wrap_length) + + _lines: List[str] = [] + _first_start = _headers[0][0] + _prose = text[:_first_start] + if _prose.strip(): + _lines = _strings.description_to_list( + _prose, + indentation, + wrap_length, + ) + while _lines and not _lines[-1]: + _lines.pop() + + for _idx, (_start, _body_start, _name) in enumerate(_headers): + _section_end = _headers[_idx + 1][0] if _idx + 1 < len(_headers) else len(text) + _body = text[_body_start:_section_end] + + if _lines: + _lines.append("") + _header_line = text[_start:_body_start].splitlines()[0].rstrip() + _lines.append(_header_line) + + if _name.lower() in GOOGLE_WRAPPABLE_SECTIONS: + _lines.extend( + _do_wrap_google_section_body( + _body, + indentation, + wrap_length, + ) + ) + else: + _lines.extend(_do_preserve_section_body(_body)) + + while _lines and not _lines[-1]: + _lines.pop() + + return _lines + + +def _do_wrap_google_section_body( + body: str, + indentation: str, + wrap_length: int, +) -> List[str]: + """Wrap each parsed Google section entry at wrap_length.""" + _lines: List[str] = [] + for _name, _type_hint, _description in _patterns.do_parse_google_entries(body): + _lines.extend( + _do_wrap_google_entry( + _name, + _type_hint, + _description, + indentation, + wrap_length, + ) + ) + return _lines + + +def _do_wrap_google_entry( # noqa: PLR0913 + name: Optional[str], + type_hint: Optional[str], + description: str, + indentation: str, + wrap_length: int, +) -> List[str]: + """Wrap one Google Args/Returns entry with hanging continuation indent.""" + _entry_indent = indentation + DEFAULT_INDENT * " " + + if name is None: + if not description: + return [] + return textwrap.wrap( + description, + width=wrap_length, + initial_indent=_entry_indent, + subsequent_indent=_entry_indent, + ) + + _type_part = f" ({type_hint})" if type_hint else "" + _prefix = f"{name}{_type_part}: " + _text = f"{_prefix}{description}".rstrip() + _continuation = indentation + (2 * DEFAULT_INDENT) * " " + + _wrapped = textwrap.wrap( + _text, + width=wrap_length, + initial_indent=_entry_indent, + subsequent_indent=_continuation, + ) + return _wrapped or [f"{_entry_indent}{_text}"] + + +def _do_preserve_section_body(body: str) -> List[str]: + """Return a non-wrappable Google section body with original lines.""" + _lines: List[str] = [] + for _line in body.splitlines(): + if _line.strip(): + _lines.append(_line.rstrip()) + elif _lines and _lines[-1]: + _lines.append("") + while _lines and not _lines[-1]: + _lines.pop() + return _lines diff --git a/tests/_data/string_files/list_patterns.toml b/tests/_data/string_files/list_patterns.toml index b7ab8d9..23b371c 100644 --- a/tests/_data/string_files/list_patterns.toml +++ b/tests/_data/string_files/list_patterns.toml @@ -137,6 +137,15 @@ strict = true style = "numpy" expected = true +[is_google_args_sphinx_style] +instring = """\ + Args: + stream (BinaryIO): Binary stream (usually a file object). + """ +strict = false +style = "sphinx" +expected = false + [is_literal_block] instring = """\ This is a description. diff --git a/tests/formatter/test_google_field_wrapping.py b/tests/formatter/test_google_field_wrapping.py new file mode 100644 index 0000000..da0e025 --- /dev/null +++ b/tests/formatter/test_google_field_wrapping.py @@ -0,0 +1,369 @@ +# pylint: skip-file +# type: ignore +# +# tests.formatter.test_google_field_wrapping.py is part of the docformatter project +# +# Copyright (C) 2012-2023 Steven Myint +# Copyright (C) 2023-2025 Doyle "weibullguy" Rowland +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Tests for wrapping Google-style Args and Returns entries. + +Each Args/Returns entry is wrapped independently. +Running the formatter twice must leave the source unchanged. +""" + +# Standard Library Imports +import sys + +# Third Party Imports +import pytest + +# docformatter Package Imports +from docformatter.format import Formatter + +NO_ARGS = [""] + + +def _formatter(test_args): + return Formatter( + test_args, + sys.stderr, + sys.stdin, + sys.stdout, + ) + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_wraps_long_prose_before_google_args(test_args, args): + """Prose before Args: is wrapped; short argument entries stay intact.""" + source = '''\ +def split_audio(): + """Split audio at silences to keep under the size limit. + + It's important to split near the middle of a silent section to prevent splitting on a spoken word and causing issues with transcription and diarization. + + Args: + silent_sections: list of silent sections in ms. + duration: total length of audio in ms + audio_size: total size of audio file in bytes + max_file_size: maximum file size that may exist after splits + + Returns: + List of points in ms to use to split the audio file into chunks. + """ +''' + expected = '''\ +def split_audio(): + """Split audio at silences to keep under the size limit. + + It's important to split near the middle of a silent section to + prevent splitting on a spoken word and causing issues with + transcription and diarization. + + Args: + silent_sections: list of silent sections in ms. + duration: total length of audio in ms + audio_size: total size of audio file in bytes + max_file_size: maximum file size that may exist after splits + + Returns: + List of points in ms to use to split the audio file into chunks. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == expected + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_wrap_google_args_entry(test_args, args): + """A long Args entry wraps under the parameter with hanging indent.""" + source = '''\ +def foo(value): + """Example. + + Args: + value: A very long argument description that exceeds the configured line width and should wrap underneath this individual parameter. + """ +''' + expected = '''\ +def foo(value): + """Example. + + Args: + value: A very long argument description that exceeds the + configured line width and should wrap underneath this + individual parameter. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == expected + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_wrap_google_args_and_returns(test_args, args): + """Args and Returns entries wrap independently without losing structure.""" + source = '''\ +def connect(host, configuration): + """Connect to the configured database. + + Args: + host: Hostname used by the database connection. + configuration: Configuration containing authentication information and connection parameters that will be used when establishing the database connection. + + Returns: + A database connection that has been initialized using the requested configuration and is ready for queries. + """ +''' + expected = '''\ +def connect(host, configuration): + """Connect to the configured database. + + Args: + host: Hostname used by the database connection. + configuration: Configuration containing authentication + information and connection parameters that will be used when + establishing the database connection. + + Returns: + A database connection that has been initialized using the + requested configuration and is ready for queries. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == expected + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_reflows_existing_manual_wrapping(test_args, args): + """Already-wrapped Args entries are unwrapped then rewrapped at width.""" + source = '''\ +def foo(value): + """Example. + + Args: + value: A very long argument description that exceeds + the configured line width and should wrap underneath + this individual parameter. + """ +''' + expected = '''\ +def foo(value): + """Example. + + Args: + value: A very long argument description that exceeds the + configured line width and should wrap underneath this + individual parameter. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == expected + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_google_args_wrapping_is_idempotent(test_args, args): + """Running the formatter twice yields the same source.""" + source = '''\ +def connect(host, configuration): + """Connect to the configured database. + + Args: + host: Hostname used by the database connection. + configuration: Configuration containing authentication information and connection parameters that will be used when establishing the database connection. + + Returns: + A database connection that has been initialized using the requested configuration and is ready for queries. + """ +''' + uut = _formatter(test_args) + once = uut._do_format_code(source) + twice = uut._do_format_code(once) + assert twice == once + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_already_correct_google_docstring_is_unchanged(test_args, args): + """A docstring that is already wrapped at the configured width is stable.""" + source = '''\ +def connect(host, configuration): + """Connect to the configured database. + + Args: + host: Hostname used by the database connection. + configuration: Configuration containing authentication + information and connection parameters that will be used when + establishing the database connection. + + Returns: + A database connection that has been initialized using the + requested configuration and is ready for queries. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == source + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_wraps_google_entry_with_type_hint(test_args, args): + """Type hints on Args entries are preserved while the description wraps.""" + source = '''\ +def load(cfg, task): + """Load a model from a configuration file. + + Args: + cfg (str): Path to the model configuration file in YAML format that should be wrapped when the path description is long enough. + task (str | None): The specific task for the model. + """ +''' + expected = '''\ +def load(cfg, task): + """Load a model from a configuration file. + + Args: + cfg (str): Path to the model configuration file in YAML format + that should be wrapped when the path description is long + enough. + task (str | None): The specific task for the model. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == expected + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_wraps_method_docstring_google_args(test_args, args): + """Method docstrings keep the extra indent while wrapping entries.""" + source = '''\ +class Client: + def connect(self, configuration): + """Connect to the configured database. + + Args: + configuration: Configuration containing authentication information and connection parameters that will be used when establishing the database connection. + """ +''' + expected = '''\ +class Client: + def connect(self, configuration): + """Connect to the configured database. + + Args: + configuration: Configuration containing authentication + information and connection parameters that will be used + when establishing the database connection. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == expected + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_preserves_raises_section(test_args, args): + """Non-Args/Returns Google sections are left unchanged.""" + source = '''\ +def load(cfg): + """Load a model from a configuration file. + + Args: + cfg: Path to the model configuration file in YAML format that should be wrapped when the path description is long enough. + + Raises: + ValueError: If the configuration file is invalid. + ImportError: If the required dependencies are not installed. + """ +''' + expected = '''\ +def load(cfg): + """Load a model from a configuration file. + + Args: + cfg: Path to the model configuration file in YAML format that + should be wrapped when the path description is long enough. + + Raises: + ValueError: If the configuration file is invalid. + ImportError: If the required dependencies are not installed. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == expected + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_leaves_url_in_prose_before_args(test_args, args): + """A URL in the description before Args: does not destroy the section.""" + source = '''\ +def fetch(url): + """Fetch a remote resource. + + See https://example.com/docs/api/v1/resources for the full protocol description that explains how resources are addressed. + + Args: + url: Resource locator passed to the client. + """ +''' + uut = _formatter(test_args) + result = uut._do_format_code(source) + assert " Args:\n url: Resource locator passed to the client." in result + assert uut._do_format_code(result) == result + + +@pytest.mark.integration +@pytest.mark.order(7) +@pytest.mark.parametrize("args", [NO_ARGS]) +def test_leaves_doctest_docstring_unchanged(test_args, args): + """Docstrings that contain doctests are not wrapped.""" + source = '''\ +def add(left, right): + """Add two numbers. + + Examples: + >>> add(1, 2) + 3 + + Args: + left: The first operand that would otherwise wrap if this were ordinary prose of sufficient length to exceed the wrap width. + right: The second operand. + """ +''' + uut = _formatter(test_args) + assert uut._do_format_code(source) == source diff --git a/tests/patterns/test_google_sections.py b/tests/patterns/test_google_sections.py new file mode 100644 index 0000000..15197ca --- /dev/null +++ b/tests/patterns/test_google_sections.py @@ -0,0 +1,95 @@ +# pylint: skip-file +# type: ignore +# +# tests.patterns.test_google_sections.py is part of the docformatter project +# +# Copyright (C) 2012-2023 Steven Myint +# Copyright (C) 2023-2025 Doyle "weibullguy" Rowland +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Unit tests for Google-style section parsing.""" + +# Third Party Imports +import pytest + +# docformatter Package Imports +from docformatter.patterns.fields import ( + do_find_google_section_headers, + do_parse_google_entries, +) + + +@pytest.mark.unit +def test_find_args_and_returns_headers(): + text = ( + "Connect to the database.\n" + "\n" + " Args:\n" + " host: Hostname.\n" + "\n" + " Returns:\n" + " A connection.\n" + ) + headers = do_find_google_section_headers(text) + names = [name for _, _, name in headers] + assert names == ["Args", "Returns"] + + +@pytest.mark.unit +def test_parse_named_args_entries_joins_continuations(): + body = ( + " host: Hostname used by the database.\n" + " configuration: Configuration containing authentication\n" + " information and connection parameters.\n" + ) + entries = do_parse_google_entries(body) + assert entries[0][0] == "host" + assert entries[0][2] == "Hostname used by the database." + assert entries[1][0] == "configuration" + assert ( + entries[1][2] + == "Configuration containing authentication information and connection " + "parameters." + ) + + +@pytest.mark.unit +def test_parse_unnamed_returns_paragraph(): + body = ( + " A database connection that has been initialized using the\n" + " requested configuration.\n" + ) + entries = do_parse_google_entries(body) + assert len(entries) == 1 + assert entries[0][0] is None + assert ( + entries[0][2] == "A database connection that has been initialized using " + "the requested configuration." + ) + + +@pytest.mark.unit +def test_parse_entry_with_type_hint(): + body = " cfg (str): Path to the model configuration file.\n" + entries = do_parse_google_entries(body) + assert entries == [ + ("cfg", "str", "Path to the model configuration file."), + ] diff --git a/tests/patterns/test_list_patterns.py b/tests/patterns/test_list_patterns.py index b402389..0c2fac3 100644 --- a/tests/patterns/test_list_patterns.py +++ b/tests/patterns/test_list_patterns.py @@ -66,6 +66,7 @@ "is_sphinx_list_numpy_style", "is_numpy_list_sphinx_style", "is_google_list_numpy_style", + "is_google_args_sphinx_style", "is_type_of_list_strict_wrap", "is_type_of_list_non_strict_wrap", "is_literal_block",