From c2c2ea9e9c45e837bec5ea2abc0a20da78a73dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Gr=C3=BCter?= Date: Sat, 7 Nov 2026 10:44:40 +0100 Subject: [PATCH 1/3] Improve reporting in case of missed section titles --- src/docstub/_docstrings.py | 4 ++-- src/docstub/_report.py | 16 ++++++++++++-- tests/test_docstrings.py | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/docstub/_docstrings.py b/src/docstub/_docstrings.py index a57badb..f4265c7 100644 --- a/src/docstub/_docstrings.py +++ b/src/docstub/_docstrings.py @@ -271,7 +271,7 @@ def doctype_to_annotation( details = error.get_context(doctype) details = details.replace("^", click.style("^", fg="red", bold=True)) stats.inc_counter("doctype_syntax_errors") - reporter.error("Invalid syntax in docstring type annotation", details=details) + reporter.error("Invalid syntax in doctype", details=details) return FallbackAnnotation except lark.visitors.VisitError as error: @@ -516,7 +516,7 @@ def _section_annotations(self, name): if param.name in annotated_params: self.reporter.warn( - "Duplicate parameter / attribute name in docstring", + f"Duplicate name in docstring section '{name}'", details=self.reporter.underline(param.name), ) continue diff --git a/src/docstub/_report.py b/src/docstub/_report.py index 773a1bd..5ac82ff 100644 --- a/src/docstub/_report.py +++ b/src/docstub/_report.py @@ -199,7 +199,7 @@ def __post_init__(self): @staticmethod def underline(line, *, char="^"): - """Underline `line` with the given `char`. + r"""Underline `line` with the given `char`. Parameters ---------- @@ -209,9 +209,20 @@ def underline(line, *, char="^"): Returns ------- underlined : str + + Examples + -------- + >>> ContextReporter.underline("foo") + 'foo\n\x1b[31m\x1b[1m^^^\x1b[0m' + >>> ContextReporter.underline("foo", char="x") + 'foo\n\x1b[31m\x1b[1mxxx\x1b[0m' + >>> ContextReporter.underline("") + '\n\x1b[31m\x1b[1m^\x1b[0m' """ assert len(char) == 1 - underlined = f"{line}\n{click.style(char * len(line), fg='red', bold=True)}" + # Underline of at least one char, so that it can indicate an empty line + underline = char * max(len(line), 1) + underlined = f"{line}\n{click.style(underline, fg='red', bold=True)}" return underlined @@ -340,6 +351,7 @@ def emit_grouped(self): groups[group_id] = groups.get(group_id, []) groups[group_id].append(record) + # TODO Presort by level and message too? # Show largest groups last groups_by_size = sorted(groups.values(), key=lambda x: len(x)) diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index 078aaa5..afef91a 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -302,3 +302,47 @@ def test_unknown_section_logged(self, caplog): assert caplog.messages == ["Warning in NumPyDoc while parsing docstring"] assert caplog.records[0].details == "Unknown section To Do" + + def test_missing_section_title_1(self, caplog): + docstring = dedent( + f"""First line. + + Returns + ------- + bool + Some number. + + NumPyDoc parses this paragraph as another parameter (or it tries to). + A new section title should precede this to avoid that. + """ + ) + annotations = DocstringAnnotations(docstring) + assert annotations.returns.value == "bool" + assert caplog.messages == ["Duplicate name in docstring section 'Returns'"] * 2 + assert caplog.records[0].details == "\n\x1b[31m\x1b[1m^\x1b[0m" + assert caplog.records[1].details == "\n\x1b[31m\x1b[1m^\x1b[0m" + + def test_missing_section_title_2(self, caplog): + docstring = dedent( + f"""First line. + + Returns + ------- + out : bool + Some number. + + NumPyDoc parses this paragraph as another parameter (or it tries to). + A new section title should precede this to avoid that. + """ + ) + annotations = DocstringAnnotations(docstring) + assert annotations.returns.value == "tuple[bool, Incomplete]" + assert caplog.messages == [ + "Invalid syntax in doctype", + "Duplicate name in docstring section 'Returns'", + ] + assert caplog.records[0].details == ( + "NumPyDoc parses this paragraph as another paramet\n" + " \x1b[31m\x1b[1m^\x1b[0m\n" + ) + assert caplog.records[1].details == "\n\x1b[31m\x1b[1m^\x1b[0m" From 4302e2e5269b191b4e1ac8c0edc65a15dca7e4c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Gr=C3=BCter?= Date: Sat, 7 Nov 2026 10:50:49 +0100 Subject: [PATCH 2/3] Apply commit hooks --- tests/test_docstrings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index afef91a..4ded4ce 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -305,7 +305,7 @@ def test_unknown_section_logged(self, caplog): def test_missing_section_title_1(self, caplog): docstring = dedent( - f"""First line. + """First line. Returns ------- @@ -324,7 +324,7 @@ def test_missing_section_title_1(self, caplog): def test_missing_section_title_2(self, caplog): docstring = dedent( - f"""First line. + """First line. Returns ------- From c96f22b48a32212480366c87d31dc90aa52a1027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Gr=C3=BCter?= Date: Sat, 7 Nov 2026 10:55:59 +0100 Subject: [PATCH 3/3] Pacify basedpyright --- tests/test_docstrings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index 4ded4ce..8458028 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -317,6 +317,7 @@ def test_missing_section_title_1(self, caplog): """ ) annotations = DocstringAnnotations(docstring) + assert annotations.returns assert annotations.returns.value == "bool" assert caplog.messages == ["Duplicate name in docstring section 'Returns'"] * 2 assert caplog.records[0].details == "\n\x1b[31m\x1b[1m^\x1b[0m" @@ -336,6 +337,7 @@ def test_missing_section_title_2(self, caplog): """ ) annotations = DocstringAnnotations(docstring) + assert annotations.returns assert annotations.returns.value == "tuple[bool, Incomplete]" assert caplog.messages == [ "Invalid syntax in doctype",