From b4100c83efa937c331e98de2e834c425d3fe74d3 Mon Sep 17 00:00:00 2001 From: dataflow-solutions-sk Date: Mon, 17 Aug 2026 11:38:05 +0200 Subject: [PATCH 1/2] fix: exclude NumpyDoc Attributes entries from Docstring.params Docstring.params is meant to list function/constructor parameters, but for NUMPYDOC-style docstrings it also picked up entries from a NumPy- style Attributes section (class attributes), since numpydoc.py tags both kinds with the same DocstringParam type and only differs via item.args[0] (param vs attribute). Fix .params to exclude attribute-tagged items, but only when the docstring style is NUMPYDOC. Other styles are left untouched: Google-style intentionally folds its own Attributes section into .params (see test_google.py::test_attributes) and the attribute- docstring merging feature (attrdoc.py / parse_from_object) relies on attribute-tagged DocstringParam items appearing in .params too, for any style. Narrowing the filter unconditionally would have broken both. Also update numpydoc.compose() to build its Parameters/Attributes/ Receives/Other Parameters sections from docstring.meta instead of the now-narrower docstring.params, so composing a previously-parsed docstring still round-trips its Attributes/Receives/Other Parameters sections correctly. Add regression tests in test_numpydoc.py covering the reporter's Attributes-only reproducer and a docstring with both Parameters and Attributes sections. Update the existing test_attributes to assert the corrected .params behavior (verifying detailed field parsing via docstring.meta instead). --- docstring_parser/common.py | 22 ++++- docstring_parser/numpydoc.py | 20 +++-- docstring_parser/tests/test_numpydoc.py | 107 ++++++++++++++++++------ 3 files changed, 115 insertions(+), 34 deletions(-) diff --git a/docstring_parser/common.py b/docstring_parser/common.py index 880e378..dab1475 100644 --- a/docstring_parser/common.py +++ b/docstring_parser/common.py @@ -183,8 +183,26 @@ def description(self) -> T.Optional[str]: @property def params(self) -> T.List[DocstringParam]: - """Return a list of information on function params.""" - return [item for item in self.meta if isinstance(item, DocstringParam)] + """Return a list of information on function params. + + For NumpydocStyle docstrings, entries coming from an "Attributes" + section (which documents class attributes, not function parameters) + are excluded -- they are a semantically distinct concept tagged with + ``args[0] == "attribute"`` by the numpydoc parser. Other styles keep + their existing behavior, since they don't share this ambiguity (e.g. + Google-style intentionally folds "Attributes" entries into + ``params``, and this property is relied upon). + """ + return [ + item + for item in self.meta + if isinstance(item, DocstringParam) + and not ( + self.style == DocstringStyle.NUMPYDOC + and item.args + and item.args[0] == "attribute" + ) + ] @property def raises(self) -> T.List[DocstringRaises]: diff --git a/docstring_parser/numpydoc.py b/docstring_parser/numpydoc.py index 7965b68..f0cbfe9 100644 --- a/docstring_parser/numpydoc.py +++ b/docstring_parser/numpydoc.py @@ -475,15 +475,20 @@ def process_sect(name: str, args: T.List[T.Any]): process_sect( "Parameters", - [item for item in docstring.params or [] if item.args[0] == "param"], + [ + item + for item in docstring.meta + if isinstance(item, DocstringParam) and item.args[0] == "param" + ], ) process_sect( "Attributes", [ item - for item in docstring.params or [] - if item.args[0] == "attribute" + for item in docstring.meta + if isinstance(item, DocstringParam) + and item.args[0] == "attribute" ], ) @@ -511,8 +516,8 @@ def process_sect(name: str, args: T.List[T.Any]): "Receives", [ item - for item in docstring.params or [] - if item.args[0] == "receives" + for item in docstring.meta + if isinstance(item, DocstringParam) and item.args[0] == "receives" ], ) @@ -520,8 +525,9 @@ def process_sect(name: str, args: T.List[T.Any]): "Other Parameters", [ item - for item in docstring.params or [] - if item.args[0] == "other_param" + for item in docstring.meta + if isinstance(item, DocstringParam) + and item.args[0] == "other_param" ], ) diff --git a/docstring_parser/tests/test_numpydoc.py b/docstring_parser/tests/test_numpydoc.py index 0118a13..042da92 100644 --- a/docstring_parser/tests/test_numpydoc.py +++ b/docstring_parser/tests/test_numpydoc.py @@ -538,7 +538,13 @@ def test_params() -> None: def test_attributes() -> None: - """Test parsing attributes.""" + """Test parsing attributes. + + Note: ``Docstring.params`` intentionally excludes "Attributes" section + entries for numpydoc-style docstrings -- attributes document class + attributes, a concept distinct from function/constructor parameters. The + raw, parsed data is still available via ``Docstring.meta``. + """ docstring = parse("Short description") assert len(docstring.params) == 0 @@ -558,23 +564,27 @@ def test_attributes() -> None: description 4 """ ) - assert len(docstring.params) == 4 - assert docstring.params[0].arg_name == "name" - assert docstring.params[0].type_name is None - assert docstring.params[0].description == "description 1" - assert not docstring.params[0].is_optional - assert docstring.params[1].arg_name == "priority" - assert docstring.params[1].type_name == "int" - assert docstring.params[1].description == "description 2" - assert not docstring.params[1].is_optional - assert docstring.params[2].arg_name == "sender" - assert docstring.params[2].type_name == "str" - assert docstring.params[2].description == "description 3" - assert docstring.params[2].is_optional - assert docstring.params[3].arg_name == "ratio" - assert docstring.params[3].type_name == "Optional[float]" - assert docstring.params[3].description == "description 4" - assert docstring.params[3].is_optional + # Attributes must NOT leak into the params convenience property. + assert len(docstring.params) == 0 + + attributes = [item for item in docstring.meta if item.args[0] == "attribute"] + assert len(attributes) == 4 + assert attributes[0].arg_name == "name" + assert attributes[0].type_name is None + assert attributes[0].description == "description 1" + assert not attributes[0].is_optional + assert attributes[1].arg_name == "priority" + assert attributes[1].type_name == "int" + assert attributes[1].description == "description 2" + assert not attributes[1].is_optional + assert attributes[2].arg_name == "sender" + assert attributes[2].type_name == "str" + assert attributes[2].description == "description 3" + assert attributes[2].is_optional + assert attributes[3].arg_name == "ratio" + assert attributes[3].type_name == "Optional[float]" + assert attributes[3].description == "description 4" + assert attributes[3].is_optional docstring = parse( """ @@ -589,15 +599,62 @@ def test_attributes() -> None: description 2 """ ) - assert len(docstring.params) == 2 - assert docstring.params[0].arg_name == "name" - assert docstring.params[0].type_name is None - assert docstring.params[0].description == ( + assert len(docstring.params) == 0 + + attributes = [item for item in docstring.meta if item.args[0] == "attribute"] + assert len(attributes) == 2 + assert attributes[0].arg_name == "name" + assert attributes[0].type_name is None + assert attributes[0].description == ( "description 1\nwith multi-line text" ) - assert docstring.params[1].arg_name == "priority" - assert docstring.params[1].type_name == "int" - assert docstring.params[1].description == "description 2" + assert attributes[1].arg_name == "priority" + assert attributes[1].type_name == "int" + assert attributes[1].description == "description 2" + + +def test_params_excludes_attributes() -> None: + """Regression test for issue #21. + + ``Docstring.params`` must not include entries from an "Attributes" + section, and when both "Parameters" and "Attributes" sections are + present, ``params`` must contain only the "Parameters" entries. + """ + # Reporter's reproducer: an Attributes-only docstring. + docstring = parse( + """ + Short description + + Attributes + ---------- + data : int + Some attribute. + """ + ) + assert docstring.params == [] + + # With both sections present, params must contain only the Parameters + # entries. + docstring = parse( + """ + Short description + + Parameters + ---------- + x : int + An actual function parameter. + + Attributes + ---------- + data : int + Some attribute. + """ + ) + assert len(docstring.params) == 1 + assert docstring.params[0].arg_name == "x" + assert [item.arg_name for item in docstring.meta if item.args[0] == "attribute"] == [ + "data" + ] def test_other_params() -> None: From b8a3b41889cd271ee5795a63b00a115eb120437c Mon Sep 17 00:00:00 2001 From: dataflow-solutions-sk Date: Mon, 17 Aug 2026 11:42:08 +0200 Subject: [PATCH 2/2] fix: dedup attrdoc against all DocstringParam meta, not narrowed .params Docstring.params now excludes NumpydocStyle attribute-tagged entries (fix for #21). attrdoc.add_attribute_docstrings() relied on docstring.params to know which attribute names were already documented, which meant an attribute already covered by an explicit numpydoc Attributes section was no longer recognized as such, causing a duplicate DocstringParam to be appended from the source-level docstring. Dedup now checks all DocstringParam items in docstring.meta regardless of style tag. Adds a regression test in test_parse_from_object.py reproducing parse_from_object() on a class with both a numpydoc Attributes section and a matching source-level attribute docstring. --- docstring_parser/attrdoc.py | 10 +++++- .../tests/test_parse_from_object.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docstring_parser/attrdoc.py b/docstring_parser/attrdoc.py index de84a85..c14a530 100644 --- a/docstring_parser/attrdoc.py +++ b/docstring_parser/attrdoc.py @@ -110,7 +110,15 @@ def add_attribute_docstrings( :param docstring: Docstring object where found attributes are added :returns: list with names of added attributes """ - params = set(p.arg_name for p in docstring.params) + # Dedup against *all* DocstringParam entries in meta (regardless of + # style-specific args[0] tag such as "param" vs "attribute"), not just + # docstring.params -- that property is intentionally narrower for + # NumpydocStyle docstrings (see Docstring.params) and would otherwise + # cause an attribute already documented via an explicit "Attributes" + # section to be re-added here, producing a duplicate entry. + params = set( + p.arg_name for p in docstring.meta if isinstance(p, DocstringParam) + ) for arg_name, (description, type_name, default) in ( AttributeDocstrings().get_attr_docs(obj).items() ): diff --git a/docstring_parser/tests/test_parse_from_object.py b/docstring_parser/tests/test_parse_from_object.py index 0dcba3d..ee6d900 100644 --- a/docstring_parser/tests/test_parse_from_object.py +++ b/docstring_parser/tests/test_parse_from_object.py @@ -3,6 +3,7 @@ from unittest.mock import patch from docstring_parser import parse_from_object +from docstring_parser.common import DocstringParam module_attr: int = 1 """Description for module_attr""" @@ -86,6 +87,41 @@ class WithoutSource: assert len(docstring.params) == 0 +def test_from_class_with_numpydoc_attributes_section_no_duplicate() -> None: + """Regression test for issue #21 (attrdoc + NumpydocStyle interaction). + + When a class uses NumpydocStyle and already documents an attribute in + an explicit "Attributes" section, ``add_attribute_docstrings`` must not + add a second, duplicate ``DocstringParam`` entry for the same attribute + just because ``Docstring.params`` (which now excludes "attribute"-tagged + numpydoc entries, see #21) no longer lists it. + """ + + class WithNumpydocAttributes: + """Short description. + + Attributes + ---------- + attr_one : str + Description from the Attributes section. + """ + + attr_one: str + """Description from the source-level docstring""" + + docstring = parse_from_object(WithNumpydocAttributes) + + matches = [ + item + for item in docstring.meta + if isinstance(item, DocstringParam) and item.arg_name == "attr_one" + ] + assert len(matches) == 1 + assert matches[0].description == ( + "Description from the Attributes section." + ) + + def test_from_function() -> None: """Test the parse of a function docstring."""