From ceadf0d630cc2ddbe2aad92cef1ae08f1382499a Mon Sep 17 00:00:00 2001 From: jihtsan <15151999611@163.com> Date: Sun, 18 Oct 2026 17:48:01 +0800 Subject: [PATCH 1/3] fix: preserve extensible reST field lists (#279) --- src/docformatter/constants.py | 8 ++ src/docformatter/patterns/fields.py | 14 +-- src/docformatter/wrappers/fields.py | 4 +- tests/formatter/test_custom_rst_fields.py | 100 ++++++++++++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 tests/formatter/test_custom_rst_fields.py diff --git a/src/docformatter/constants.py b/src/docformatter/constants.py index 5b95a8e..c2554a7 100644 --- a/src/docformatter/constants.py +++ b/src/docformatter/constants.py @@ -139,6 +139,14 @@ ) """Regular expression to use for finding reST section headers.""" +# Field names are extensible. Match the reST marker separately so custom fields +# do not need to be added to Sphinx's legacy, permissive pattern below. +# https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists +REST_FIELD_REGEX = ( + r":(?![: \t])(?:[^:\\\r\n]|\\.|:(?![ `\t\r\n]|$))*(?{REST_FIELD_REGEX})|{SPHINX_REGEX}", + text, + flags=re.MULTILINE, + ): + _marker_group = "field" if _field.group("field") is not None else 0 + _field_idx.append(_field.span(_marker_group)) _wrap_parameters = True return _field_idx, _wrap_parameters @@ -286,7 +290,7 @@ def is_sphinx_field_list(line: str) -> Union[Match[str], None]: Sphinx field lists have the following pattern: :parameter: description """ - return re.match(SPHINX_REGEX, line) + return re.match(SPHINX_REGEX, line) or re.match(REST_FIELD_REGEX, line) # TODO: Add a USER_DEFINED_REGEX to constants.py and use that instead of the diff --git a/src/docformatter/wrappers/fields.py b/src/docformatter/wrappers/fields.py index a003f9d..c0e7218 100644 --- a/src/docformatter/wrappers/fields.py +++ b/src/docformatter/wrappers/fields.py @@ -125,8 +125,8 @@ def _do_join_field_body(text: str, field_idx: list[tuple[int, int]], idx: int) - [_line.strip() for _line in _field_body.splitlines()] ).strip() - # Add a space before the field body unless the field body is a link. - if not _field_body.startswith("`") and _field_body: + # Field markers require whitespace before any body, including inline links. + if _field_body: _field_body = f" {_field_body}" # Is there a blank line between field lists? Keep it if so. diff --git a/tests/formatter/test_custom_rst_fields.py b/tests/formatter/test_custom_rst_fields.py new file mode 100644 index 0000000..1b8074d --- /dev/null +++ b/tests/formatter/test_custom_rst_fields.py @@ -0,0 +1,100 @@ +# pylint: skip-file +# type: ignore +# +# tests.formatter.test_custom_rst_fields.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. +"""Regression coverage for custom reStructuredText fields (issue #279).""" + +# Standard Library Imports +import sys + +# Third Party Imports +import pytest + +# docformatter Package Imports +from docformatter.format import Formatter +from docformatter.patterns.fields import do_find_field_lists, is_sphinx_field_list + + +@pytest.mark.parametrize( + "field", + [ + "calls", + "custom-name", + "param dict[str, int] value", + "custom:name", + r"escaped\: name", + "字段", + ], +) +def test_custom_field_markers(field): + text = "Prose mentions :func:`a_role`.\n :" + field + ": body\n" + indices, wrap = do_find_field_lists(text, "sphinx") + assert wrap + assert [text[start:end] for start, end in indices] == [":" + field + ":"] + assert is_sphinx_field_list(":" + field + ": body") + + +@pytest.mark.parametrize( + "text", + [ + "Prose :calls: text", + ":class:`Thing`", + ":: text", + ": calls: text", + ":name : text", + ":calls:body", + ], +) +def test_prose_and_roles_are_not_field_markers(text): + assert do_find_field_lists(text, "sphinx")[0] == [] + assert not is_sphinx_field_list(text) + + +@pytest.mark.parametrize("args", [[""], ["--black", ""]]) +@pytest.mark.parametrize( + "field", ["calls", "custom-name", "param dict[str, int] value"] +) +def test_custom_field_wrapping_is_indented_and_idempotent(test_args, args, field): + source = ( + "def f():\n" + ' """Create a file.\n\n' + " :" + field + ": `PUT /repos/{owner}/{repo}/contents/{path} " + "`_\n" + " :param path: string, (required), path of the file in the repository\n" + " :param message: string, (required), commit message\n" + ' """\n' + ) + formatter = Formatter(test_args, sys.stderr, sys.stdin, sys.stdout) + result = formatter._do_format_code(source) + lines = result.splitlines() + start = next( + i for i, line in enumerate(lines) if line.startswith(" :" + field + ":") + ) + end = next(i for i, line in enumerate(lines) if line.startswith(" :param path:")) + assert lines[start].startswith(" :" + field + ": ") + assert end > start + 1 + assert all(line.startswith(" ") for line in lines[start + 1 : end]) + assert formatter._do_format_code(result) == result From 5696f5e91565effa5278a391da0f9517193b851b Mon Sep 17 00:00:00 2001 From: jihtsan <15151999611@163.com> Date: Sun, 18 Oct 2026 17:50:07 +0800 Subject: [PATCH 2/3] docs: require valid spacing before inline-link field bodies --- docs/source/requirements.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/requirements.rst b/docs/source/requirements.rst index 2be1184..a7822df 100644 --- a/docs/source/requirements.rst +++ b/docs/source/requirements.rst @@ -215,7 +215,7 @@ the requirement falls in, the type of requirement, and whether ' docformatter_10.4.1', ' Shall ignore docstrings in other styles when using Sphinx style.', ' Style', ' Shall', ' Yes' ' docformatter_10.4.2', ' Shall wrap Sphinx-style parameter descriptions that exceed wrap length when using Sphinx style.', ' Shall', ' Yes' ' docformatter_10.4.3', ' Shall ensure one blank space between a field name and field body.', ' Style', ' Shall', ' Yes [PR #220]' - ' docformatter_10.4.3.1', ' Shall NOT add a blank space after a field name when the field body is a link.', ' Style', ' Shall', ' Yes [PR #220]' + ' docformatter_10.4.3.1', ' Shall add a blank space after a field name when the field body is a link, as required by reST field-marker syntax.', ' Style', ' Shall', ' Yes [Issue #279]' ' docformatter_10.4.3.2', ' Shall NOT add a blank space after a field name when there is no field body.', ' Style', ' Shall', ' Yes [PR #220]' ' docformatter_10.5', ' Should format docstrings compatible with black.', ' Style', ' Should', ' Yes [PR #192]' ' docformatter_10.5.1', ' Should wrap summaries at 88 characters by default in black mode.', ' Style', ' Should', ' Yes' From 8b2d423d93960da65e34340cbd24a7c10361ffbe Mon Sep 17 00:00:00 2001 From: jihtsan <15151999611@163.com> Date: Sun, 18 Oct 2026 17:52:01 +0800 Subject: [PATCH 3/3] fix: preserve complex custom field body protection --- src/docformatter/patterns/fields.py | 2 +- tests/formatter/test_custom_rst_fields.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/docformatter/patterns/fields.py b/src/docformatter/patterns/fields.py index 7274dec..aeb3b10 100644 --- a/src/docformatter/patterns/fields.py +++ b/src/docformatter/patterns/fields.py @@ -290,7 +290,7 @@ def is_sphinx_field_list(line: str) -> Union[Match[str], None]: Sphinx field lists have the following pattern: :parameter: description """ - return re.match(SPHINX_REGEX, line) or re.match(REST_FIELD_REGEX, line) + return re.match(SPHINX_REGEX, line) # TODO: Add a USER_DEFINED_REGEX to constants.py and use that instead of the diff --git a/tests/formatter/test_custom_rst_fields.py b/tests/formatter/test_custom_rst_fields.py index 1b8074d..97d2ce1 100644 --- a/tests/formatter/test_custom_rst_fields.py +++ b/tests/formatter/test_custom_rst_fields.py @@ -35,7 +35,7 @@ # docformatter Package Imports from docformatter.format import Formatter -from docformatter.patterns.fields import do_find_field_lists, is_sphinx_field_list +from docformatter.patterns.fields import do_find_field_lists @pytest.mark.parametrize( @@ -54,7 +54,6 @@ def test_custom_field_markers(field): indices, wrap = do_find_field_lists(text, "sphinx") assert wrap assert [text[start:end] for start, end in indices] == [":" + field + ":"] - assert is_sphinx_field_list(":" + field + ": body") @pytest.mark.parametrize( @@ -70,7 +69,6 @@ def test_custom_field_markers(field): ) def test_prose_and_roles_are_not_field_markers(text): assert do_find_field_lists(text, "sphinx")[0] == [] - assert not is_sphinx_field_list(text) @pytest.mark.parametrize("args", [[""], ["--black", ""]]) @@ -98,3 +96,18 @@ def test_custom_field_wrapping_is_indented_and_idempotent(test_args, args, field assert end > start + 1 assert all(line.startswith(" ") for line in lines[start + 1 : end]) assert formatter._do_format_code(result) == result + + +@pytest.mark.parametrize("args", [[""], ["--black", ""]]) +@pytest.mark.parametrize( + "body", + [ + " - GET first\n - POST second\n", + " 1. GET first\n 2. POST second\n", + " >>> print(1)\n 1\n", + ], +) +def test_complex_custom_field_body_is_preserved(test_args, args, body): + source = '"""Summary.\n\n:calls: Available operations.\n\n' + body + '"""\n' + formatter = Formatter(test_args, sys.stderr, sys.stdin, sys.stdout) + assert formatter._do_format_code(source) == source