From fd498d49c83332759a31a6a57ee825b9143c790e Mon Sep 17 00:00:00 2001 From: unknown <55wokqe9@anonaddy.me> Date: Sat, 4 Jan 2025 12:06:54 +0100 Subject: [PATCH 01/14] ENH: implement typehints for parameters --- numpydoc/docscrape.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index bfc2840e..618e7cba 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -237,7 +237,8 @@ def _parse_param_list(self, content, single_element_is_type=False): if single_element_is_type: arg_name, arg_type = "", header else: - arg_name, arg_type = header, "" + arg_name = header + arg_type = self._get_type_from_signature(header) desc = r.read_to_next_unindented_line() desc = dedent_lines(desc) @@ -247,6 +248,9 @@ def _parse_param_list(self, content, single_element_is_type=False): return params + def _get_type_from_signature(self, arg_name: str) -> str: + return "" + # See also supports the following formats. # # @@ -577,6 +581,7 @@ def dedent_lines(lines): class FunctionDoc(NumpyDocString): def __init__(self, func, role="func", doc=None, config=None): self._f = func + self._signature = inspect.signature(func) self._role = role # e.g. "func" or "meth" if doc is None: @@ -610,6 +615,13 @@ def __str__(self): out += super().__str__(func_role=self._role) return out + def _get_type_from_signature(self, arg_name: str) -> str: + parameter = self._signature.parameters[arg_name.replace("*", "")] + if parameter.annotation == parameter.empty: + return "" + else: + return str(parameter.annotation) + class ObjDoc(NumpyDocString): def __init__(self, obj, doc=None, config=None): @@ -645,6 +657,9 @@ def __init__(self, cls, doc=None, modulename="", func_doc=FunctionDoc, config=No raise ValueError("No class or documentation string given") doc = pydoc.getdoc(cls) + if cls is not None: + self._signature = inspect.signature(cls.__init__) + NumpyDocString.__init__(self, doc) _members = config.get("members", []) @@ -728,6 +743,16 @@ def _is_show_member(self, name): or name in self._cls.__dict__ ) + def _get_type_from_signature(self, arg_name: str) -> str: + try: + parameter = self._signature.parameters[arg_name.replace("*", "")] + if parameter.annotation == parameter.empty: + return "" + else: + return str(parameter.annotation) + except AttributeError: + return "" + def get_doc_object( obj, From b6a41c48e80a5d8ffd5d42a9086e29547a47c47a Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Wed, 8 Jan 2025 14:36:03 +0000 Subject: [PATCH 02/14] ENH: fix error when no signature for function For some functions, the signature cannot be obtained and so inspect.signature raises a ValueError, which can crash the sphinx build. This has been fixed by catching the exception --- numpydoc/docscrape.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 618e7cba..b374eb62 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -581,7 +581,11 @@ def dedent_lines(lines): class FunctionDoc(NumpyDocString): def __init__(self, func, role="func", doc=None, config=None): self._f = func - self._signature = inspect.signature(func) + try: + self._signature = inspect.signature(func) + except ValueError: + self._signature = None + self._role = role # e.g. "func" or "meth" if doc is None: @@ -616,7 +620,11 @@ def __str__(self): return out def _get_type_from_signature(self, arg_name: str) -> str: + if self._signature is None: + return "" + parameter = self._signature.parameters[arg_name.replace("*", "")] + if parameter.annotation == parameter.empty: return "" else: From 577d9a9dcf1964f49b053846c09c255f8ed25922 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Wed, 8 Jan 2025 15:40:29 +0000 Subject: [PATCH 03/14] ENH: increase ClassDoc._get_type_from_signature robustness For ClassDoc, the _get_type_from_signature method handles not only the docs for __init__, but also for the Attribute and Method etc. sections, whose typehints cannot be obtained from signature(__init__). Therefore, further code has been added that attempts to find the type hint from different sources and should work for functions, methods, properties, and attributes. --- numpydoc/docscrape.py | 52 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index b374eb62..35cd59d0 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -9,6 +9,7 @@ from collections import namedtuple from collections.abc import Callable, Mapping from functools import cached_property +from typing import ForwardRef, get_type_hints from warnings import warn @@ -666,7 +667,12 @@ def __init__(self, cls, doc=None, modulename="", func_doc=FunctionDoc, config=No doc = pydoc.getdoc(cls) if cls is not None: - self._signature = inspect.signature(cls.__init__) + try: + self._signature = inspect.signature(cls.__init__) + except ValueError: + self._signature = None + else: + self._signature = None NumpyDocString.__init__(self, doc) @@ -752,14 +758,50 @@ def _is_show_member(self, name): ) def _get_type_from_signature(self, arg_name: str) -> str: + if self._signature is None: + return "" + + arg_name = arg_name.replace("*", "") + try: + parameter = self._signature.parameters[arg_name] + except KeyError: + return self._find_type_hint(self._cls, arg_name) + + if parameter.annotation == parameter.empty: + return "" + else: + return str(parameter.annotation) + + @staticmethod + def _find_type_hint(cls: type, arg_name: str) -> str: + type_hints = get_type_hints(cls) try: - parameter = self._signature.parameters[arg_name.replace("*", "")] - if parameter.annotation == parameter.empty: + annotation = type_hints[arg_name] + except KeyError: + try: + attr = getattr(cls, arg_name) + except AttributeError: return "" + + attr = attr.fget if isinstance(attr, property) else attr + + if callable(attr): + try: + signature = inspect.signature(attr) + except ValueError: + return "" + + if signature.return_annotation == signature.empty: + return "" + else: + return str(signature.return_annotation) else: - return str(parameter.annotation) + return type(attr).__name__ + + try: + return str(annotation.__name__) except AttributeError: - return "" + return str(annotation) def get_doc_object( From dd8c5d823b0e2bf212b34c8696c8fe0d136b9e83 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Wed, 15 Jan 2025 10:07:19 +0000 Subject: [PATCH 04/14] ENH: Remove unused import ForwardRef is no longer necessary since the functionality is handled via typing.get_type_hints --- numpydoc/docscrape.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 35cd59d0..2695f7fa 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -9,7 +9,7 @@ from collections import namedtuple from collections.abc import Callable, Mapping from functools import cached_property -from typing import ForwardRef, get_type_hints +from typing import get_type_hints from warnings import warn From 3180d0028e2c08f02e1593da31a461dd1a454d4e Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 10:19:04 +0100 Subject: [PATCH 05/14] Fix handling of combined parameters and missing space before colon --- numpydoc/docscrape.py | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 2695f7fa..3963c802 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -624,13 +624,37 @@ def _get_type_from_signature(self, arg_name: str) -> str: if self._signature is None: return "" - parameter = self._signature.parameters[arg_name.replace("*", "")] + arg_name = arg_name.replace("*", "") + try: + parameter = self._signature.parameters[arg_name] + except KeyError: + parameter = self._handle_combined_parameters(arg_name) + if parameter is None: + return "" if parameter.annotation == parameter.empty: return "" else: return str(parameter.annotation) + def _handle_combined_parameters(self, arg_names: str): + arg_names = arg_names.split(',') + try: + parameter1 = self._signature.parameters[arg_names[0].strip()] + except KeyError: + return None + + for arg_name in arg_names[1:]: + try: + parameter = self._signature.parameters[arg_name.strip()] + except KeyError: + return None + + if parameter.annotation != parameter1.annotation: + return None + + return parameter1 + class ObjDoc(NumpyDocString): def __init__(self, obj, doc=None, config=None): @@ -765,13 +789,25 @@ def _get_type_from_signature(self, arg_name: str) -> str: try: parameter = self._signature.parameters[arg_name] except KeyError: - return self._find_type_hint(self._cls, arg_name) + return self._handle_combined_parameters(arg_name, self._cls) if parameter.annotation == parameter.empty: return "" else: return str(parameter.annotation) + def _handle_combined_parameters(self, arg_names: str, cls: type): + arg_names = arg_names.split(',') + hint1 = self._find_type_hint(cls, arg_names[0]) + + for arg_name in arg_names[1:]: + hint = self._find_type_hint(cls, arg_name) + + if hint != hint1: + return "" + + return hint1 + @staticmethod def _find_type_hint(cls: type, arg_name: str) -> str: type_hints = get_type_hints(cls) From 8c73126b28655bac3140a59b68c87ae47c3ab51e Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 14:16:27 +0100 Subject: [PATCH 06/14] Add tests for function type hints Also fixes the function type hints for cases where annotations were not imported from __future__ --- numpydoc/docscrape.py | 5 ++- numpydoc/tests/test_docscrape.py | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 3963c802..2e582a3c 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -635,7 +635,10 @@ def _get_type_from_signature(self, arg_name: str) -> str: if parameter.annotation == parameter.empty: return "" else: - return str(parameter.annotation) + if type(parameter.annotation) == type: + return str(parameter.annotation.__name__) + else: + return str(parameter.annotation) def _handle_combined_parameters(self, arg_names: str): arg_names = arg_names.split(',') diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 4cafc762..cf6ce364 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1,5 +1,6 @@ import re import textwrap +import typing import warnings from collections import namedtuple from copy import deepcopy @@ -1715,6 +1716,68 @@ class MyFooWithParams(foo): assert sds["Parameters"][1].desc[0] == "The baz attribute" +T = typing.TypeVar("T") + +@pytest.mark.parametrize( + "typ,expected", + [ + (int, "int"), + (str, "str"), + (float, "float"), + (complex, "complex"), + (None, "None"), + (bool, "bool"), + (list, "list"), + (list[int], "list[int]"), + (set, "set"), + (set[str], "set[str]"), + (frozenset, "frozenset"), + (frozenset[str], "frozenset[str]"), + (tuple, "tuple"), + (tuple[int], "tuple[int]"), + (tuple[int, float, complex], "tuple[int, float, complex]"), + (tuple[int, ...], "tuple[int, ...]"), + (range, "range"), + (dict, "dict"), + (dict[str, int], "dict[str, int]"), + (dict[str, dict[int, list[float]]], "dict[str, dict[int, list[float]]]"), + (typing.Union[int, float], "typing.Union[int, float]"), + (typing.Optional[str], "typing.Optional[str]"), + (typing.Callable[[], float], "typing.Callable[[], float]"), + (typing.Callable[[int, int], str], "typing.Callable[[int, int], str]"), + (typing.Callable[[int, Exception], None], + "typing.Callable[[int, Exception], NoneType]"), + (typing.Callable[..., typing.Awaitable[None]], + "typing.Callable[..., typing.Awaitable[NoneType]]"), + (typing.Callable[[T], T], "typing.Callable[[~T], ~T]"), + (typing.Any, "typing.Any"), + (typing.Literal["a", "b", "c"], "typing.Literal['a', 'b', 'c']"), + (typing.Annotated[float, "min=0", "max=42"], "typing.Annotated[float, 'min=0', 'max=42']"), + (typing.Annotated[dict[str, dict[str, list[typing.Union[float, tuple[int, complex]]]]], + typing.Callable[[], typing.NoReturn], "help='description'"], + "typing.Annotated[dict[str, dict[str, list[typing.Union[float, tuple[int, complex]]]]], " + "typing.Callable[[], typing.NoReturn], \"help='description'\"]"), + ] +) +def test_type_hints_func(typ, expected): + def foo(a: typ, b: typ): + """Short description\n + Parameters + ---------- + a + Description for a. + + Other Parameters + ---------------- + b + Description for b. + """ + + doc = FunctionDoc(foo) + assert doc["Parameters"][0].type == expected + assert doc["Other Parameters"][0].type == expected + + if __name__ == "__main__": import pytest From a8df2b9776412bf92b9de35b76e97c347c351417 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 15:18:16 +0100 Subject: [PATCH 07/14] Add tests for class parameters and attributes Also fixes class parameters type hints without annotations and enables full Annotated rendering for attributes --- numpydoc/docscrape.py | 15 ++-- numpydoc/tests/test_docscrape.py | 135 +++++++++++++++++++++---------- 2 files changed, 102 insertions(+), 48 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 2e582a3c..1e1b47ea 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -634,11 +634,10 @@ def _get_type_from_signature(self, arg_name: str) -> str: if parameter.annotation == parameter.empty: return "" + elif type(parameter.annotation) == type: + return str(parameter.annotation.__name__) else: - if type(parameter.annotation) == type: - return str(parameter.annotation.__name__) - else: - return str(parameter.annotation) + return str(parameter.annotation) def _handle_combined_parameters(self, arg_names: str): arg_names = arg_names.split(',') @@ -796,6 +795,8 @@ def _get_type_from_signature(self, arg_name: str) -> str: if parameter.annotation == parameter.empty: return "" + elif type(parameter.annotation) == type: + return str(parameter.annotation.__name__) else: return str(parameter.annotation) @@ -813,7 +814,7 @@ def _handle_combined_parameters(self, arg_names: str, cls: type): @staticmethod def _find_type_hint(cls: type, arg_name: str) -> str: - type_hints = get_type_hints(cls) + type_hints = get_type_hints(cls, include_extras=True) try: annotation = type_hints[arg_name] except KeyError: @@ -837,9 +838,9 @@ def _find_type_hint(cls: type, arg_name: str) -> str: else: return type(attr).__name__ - try: + if type(annotation) == type: return str(annotation.__name__) - except AttributeError: + else: return str(annotation) diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index cf6ce364..0469df28 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1718,47 +1718,46 @@ class MyFooWithParams(foo): T = typing.TypeVar("T") -@pytest.mark.parametrize( - "typ,expected", - [ - (int, "int"), - (str, "str"), - (float, "float"), - (complex, "complex"), - (None, "None"), - (bool, "bool"), - (list, "list"), - (list[int], "list[int]"), - (set, "set"), - (set[str], "set[str]"), - (frozenset, "frozenset"), - (frozenset[str], "frozenset[str]"), - (tuple, "tuple"), - (tuple[int], "tuple[int]"), - (tuple[int, float, complex], "tuple[int, float, complex]"), - (tuple[int, ...], "tuple[int, ...]"), - (range, "range"), - (dict, "dict"), - (dict[str, int], "dict[str, int]"), - (dict[str, dict[int, list[float]]], "dict[str, dict[int, list[float]]]"), - (typing.Union[int, float], "typing.Union[int, float]"), - (typing.Optional[str], "typing.Optional[str]"), - (typing.Callable[[], float], "typing.Callable[[], float]"), - (typing.Callable[[int, int], str], "typing.Callable[[int, int], str]"), - (typing.Callable[[int, Exception], None], - "typing.Callable[[int, Exception], NoneType]"), - (typing.Callable[..., typing.Awaitable[None]], - "typing.Callable[..., typing.Awaitable[NoneType]]"), - (typing.Callable[[T], T], "typing.Callable[[~T], ~T]"), - (typing.Any, "typing.Any"), - (typing.Literal["a", "b", "c"], "typing.Literal['a', 'b', 'c']"), - (typing.Annotated[float, "min=0", "max=42"], "typing.Annotated[float, 'min=0', 'max=42']"), - (typing.Annotated[dict[str, dict[str, list[typing.Union[float, tuple[int, complex]]]]], - typing.Callable[[], typing.NoReturn], "help='description'"], - "typing.Annotated[dict[str, dict[str, list[typing.Union[float, tuple[int, complex]]]]], " - "typing.Callable[[], typing.NoReturn], \"help='description'\"]"), - ] -) +type_hints = [ + (None, "None"), + (int, "int"), + (str, "str"), + (float, "float"), + (complex, "complex"), + (bool, "bool"), + (list, "list"), + (list[int], "list[int]"), + (set, "set"), + (set[str], "set[str]"), + (frozenset, "frozenset"), + (frozenset[str], "frozenset[str]"), + (tuple, "tuple"), + (tuple[int], "tuple[int]"), + (tuple[int, float, complex], "tuple[int, float, complex]"), + (tuple[int, ...], "tuple[int, ...]"), + (range, "range"), + (dict, "dict"), + (dict[str, int], "dict[str, int]"), + (dict[str, dict[int, list[float]]], "dict[str, dict[int, list[float]]]"), + (typing.Union[int, float], "typing.Union[int, float]"), + (typing.Optional[str], "typing.Optional[str]"), + (typing.Callable[[], float], "typing.Callable[[], float]"), + (typing.Callable[[int, int], str], "typing.Callable[[int, int], str]"), + (typing.Callable[[int, Exception], None], + "typing.Callable[[int, Exception], NoneType]"), + (typing.Callable[..., typing.Awaitable[None]], + "typing.Callable[..., typing.Awaitable[NoneType]]"), + (typing.Callable[[T], T], "typing.Callable[[~T], ~T]"), + (typing.Any, "typing.Any"), + (typing.Literal["a", "b", "c"], "typing.Literal['a', 'b', 'c']"), + (typing.Annotated[float, "min=0", "max=42"], "typing.Annotated[float, 'min=0', 'max=42']"), + (typing.Annotated[dict[str, dict[str, list[typing.Union[float, tuple[int, complex]]]]], + typing.Callable[[], typing.NoReturn], "help='description'"], + "typing.Annotated[dict[str, dict[str, list[typing.Union[float, tuple[int, complex]]]]], " + "typing.Callable[[], typing.NoReturn], \"help='description'\"]"), +] + +@pytest.mark.parametrize("typ,expected", type_hints) def test_type_hints_func(typ, expected): def foo(a: typ, b: typ): """Short description\n @@ -1778,6 +1777,60 @@ def foo(a: typ, b: typ): assert doc["Other Parameters"][0].type == expected +@pytest.mark.parametrize("typ,expected", type_hints) +def test_type_hints_class_parameters(typ, expected): + class Foo: + """Short description\n + Parameters + ---------- + a + Description for a. + + Other Parameters + ---------------- + b + Description for b. + """ + + def __init__(self, a: typ, b: typ): ... + + doc = ClassDoc(Foo) + assert doc["Parameters"][0].type == expected + assert doc["Other Parameters"][0].type == expected + + +type_hints_attributes = type_hints.copy() +type_hints_attributes[0] = (None, "NoneType") + +@pytest.mark.parametrize("typ,expected", type_hints_attributes) +def test_type_hints_class_attributes(typ, expected): + class Foo: + """Short description\n + Attributes + ---------- + a + Description for a. + """ + a: typ + + class Bar(Foo): + """Short description\n + Attributes + ---------- + a + Description for a. + b + Description for b. + """ + b: typ + + doc_foo = ClassDoc(Foo) + doc_bar = ClassDoc(Bar) + assert doc_foo["Attributes"][0].type == expected + assert doc_bar["Attributes"][0].type == expected + assert doc_bar["Attributes"][1].type == expected + + if __name__ == "__main__": import pytest From 98a8ecd7e3b731e66421d444f6e0dc2f0c562f78 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 16:15:40 +0100 Subject: [PATCH 08/14] Add tests for class methods section type hints Also introduces a refactor, pulling out a helper function that turns an annotation to a string --- numpydoc/docscrape.py | 28 ++++++++++++---------------- numpydoc/tests/test_docscrape.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 1e1b47ea..a2b87f69 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -632,12 +632,7 @@ def _get_type_from_signature(self, arg_name: str) -> str: if parameter is None: return "" - if parameter.annotation == parameter.empty: - return "" - elif type(parameter.annotation) == type: - return str(parameter.annotation.__name__) - else: - return str(parameter.annotation) + return _annotation_to_string(parameter.annotation) def _handle_combined_parameters(self, arg_names: str): arg_names = arg_names.split(',') @@ -793,12 +788,7 @@ def _get_type_from_signature(self, arg_name: str) -> str: except KeyError: return self._handle_combined_parameters(arg_name, self._cls) - if parameter.annotation == parameter.empty: - return "" - elif type(parameter.annotation) == type: - return str(parameter.annotation.__name__) - else: - return str(parameter.annotation) + return _annotation_to_string(parameter.annotation) def _handle_combined_parameters(self, arg_names: str, cls: type): arg_names = arg_names.split(',') @@ -831,10 +821,7 @@ def _find_type_hint(cls: type, arg_name: str) -> str: except ValueError: return "" - if signature.return_annotation == signature.empty: - return "" - else: - return str(signature.return_annotation) + return _annotation_to_string(signature.return_annotation) else: return type(attr).__name__ @@ -844,6 +831,15 @@ def _find_type_hint(cls: type, arg_name: str) -> str: return str(annotation) +def _annotation_to_string(annotation) -> str: + if annotation == inspect.Signature.empty: + return "" + elif type(annotation) == type: + return str(annotation.__name__) + else: + return str(annotation) + + def get_doc_object( obj, what=None, diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 0469df28..e5e63d33 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1831,6 +1831,22 @@ class Bar(Foo): assert doc_bar["Attributes"][1].type == expected +@pytest.mark.parametrize("typ,expected", type_hints) +def test_type_hints_class_methods(typ, expected): + class Foo: + """Short description\n + Methods + ------- + a + Description for a. + """ + + def a(self) -> typ: ... + + doc = ClassDoc(Foo) + assert doc["Methods"][0].type == expected + + if __name__ == "__main__": import pytest From 0923a1e74763730281168a7aa36713580e9219e0 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 16:43:29 +0100 Subject: [PATCH 09/14] Add test for *args and **kwargs type hints --- numpydoc/tests/test_docscrape.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index e5e63d33..7553ff57 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1847,6 +1847,34 @@ def a(self) -> typ: ... assert doc["Methods"][0].type == expected +def test_type_hints_args_kwargs(): + def foo(*args: int, **kwargs: float): + """Short description\n + Parameters + ---------- + *args + Args description. + **kwargs + Kwargs description. + """ + + class Bar: + """Short description\n + Parameters + ---------- + *args + Args description. + **kwargs + Kwargs description. + """ + def __init__(self, *args: int, **kwargs: float): ... + + for cls, obj in zip((FunctionDoc, ClassDoc), (foo, Bar)): + doc = cls(obj) + assert doc["Parameters"][0].type == "int" + assert doc["Parameters"][1].type == "float" + + if __name__ == "__main__": import pytest From d2b0d943ef7b1b6d4bde217c0943e4d2a3c7d90e Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 17:49:07 +0100 Subject: [PATCH 10/14] Add tests for combined parameters and attributes Tests both when parameters with the same type are combined and when parameters of different types are combined. Includes a refactor where the _handle_combined_parameters method was pulled into the base class --- numpydoc/docscrape.py | 51 ++++++++++---------- numpydoc/tests/test_docscrape.py | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 24 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index a2b87f69..f1131047 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -252,6 +252,25 @@ def _parse_param_list(self, content, single_element_is_type=False): def _get_type_from_signature(self, arg_name: str) -> str: return "" + @staticmethod + def _handle_combined_parameters(arg_names: str, parameters: dict[str, inspect.Parameter]): + arg_names = arg_names.split(',') + try: + parameter1 = parameters[arg_names[0].strip()] + except KeyError: + return None + + for arg_name in arg_names[1:]: + try: + parameter = parameters[arg_name.strip()] + except KeyError: + return None + + if parameter.annotation != parameter1.annotation: + return None + + return parameter1 + # See also supports the following formats. # # @@ -628,30 +647,12 @@ def _get_type_from_signature(self, arg_name: str) -> str: try: parameter = self._signature.parameters[arg_name] except KeyError: - parameter = self._handle_combined_parameters(arg_name) + parameter = self._handle_combined_parameters(arg_name, self._signature.parameters) if parameter is None: return "" return _annotation_to_string(parameter.annotation) - def _handle_combined_parameters(self, arg_names: str): - arg_names = arg_names.split(',') - try: - parameter1 = self._signature.parameters[arg_names[0].strip()] - except KeyError: - return None - - for arg_name in arg_names[1:]: - try: - parameter = self._signature.parameters[arg_name.strip()] - except KeyError: - return None - - if parameter.annotation != parameter1.annotation: - return None - - return parameter1 - class ObjDoc(NumpyDocString): def __init__(self, obj, doc=None, config=None): @@ -786,18 +787,20 @@ def _get_type_from_signature(self, arg_name: str) -> str: try: parameter = self._signature.parameters[arg_name] except KeyError: - return self._handle_combined_parameters(arg_name, self._cls) + parameter = self._handle_combined_parameters(arg_name, self._signature.parameters) + if parameter is None: + return self._handle_combined_attributes(arg_name, self._cls) return _annotation_to_string(parameter.annotation) - def _handle_combined_parameters(self, arg_names: str, cls: type): + def _handle_combined_attributes(self, arg_names: str, cls: type): arg_names = arg_names.split(',') - hint1 = self._find_type_hint(cls, arg_names[0]) + hint1 = self._find_type_hint(cls, arg_names[0].strip()) for arg_name in arg_names[1:]: - hint = self._find_type_hint(cls, arg_name) + hint = self._find_type_hint(cls, arg_name.strip()) - if hint != hint1: + if hint != hint1: return "" return hint1 diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 7553ff57..8a849b76 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1875,6 +1875,86 @@ def __init__(self, *args: int, **kwargs: float): ... assert doc["Parameters"][1].type == "float" +def test_type_hints_combined_parameters_valid(): + def foo(a: int, b: int, c: int, d: int): + """Short description\n + Parameters + ---------- + a, b, c, d + Combined description. + """ + + class Bar: + """Short description\n + Parameters + ---------- + a, b, c, d + Combined description. + """ + def __init__(self, a: int, b: int, c: int, d: int): ... + + for cls, obj in zip((FunctionDoc, ClassDoc), (foo, Bar)): + doc = cls(obj) + assert doc["Parameters"][0].type == "int" + + +def test_type_hints_combined_parameters_invalid(): + def foo(a: int, b: float, c: str, d: list): + """Short description\n + Parameters + ---------- + a, b, c, d + Combined description. + """ + + class Bar: + """Short description\n + Parameters + ---------- + a, b, c, d + Combined description. + """ + def __init__(self, a: int, b: float, c: str, d: list): ... + + for cls, obj in zip((FunctionDoc, ClassDoc), (foo, Bar)): + doc = cls(obj) + assert doc["Parameters"][0].type == "" + + +def test_type_hints_combined_attributes_valid(): + class Bar: + """Short description\n + Attributes + ---------- + a, b, c, d + Combined description. + """ + a: int + b: int + c: int + d: int + + doc = ClassDoc(Bar) + assert doc["Attributes"][0].type == "int" + + +def test_type_hints_combined_attributes_invalid(): + class Bar: + """Short description\n + Attributes + ---------- + a, b, c, d + Combined description. + """ + a: int + b: float + c: str + d: list + + doc = ClassDoc(Bar) + assert doc["Attributes"][0].type == "" + + if __name__ == "__main__": import pytest From 66e19cbfa884a8ffeee0908d93ccb2aaeaedba86 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 17:58:01 +0100 Subject: [PATCH 11/14] Add tests for class properties in attributes --- numpydoc/tests/test_docscrape.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 8a849b76..f3108ee7 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1831,6 +1831,21 @@ class Bar(Foo): assert doc_bar["Attributes"][1].type == expected +@pytest.mark.parametrize("typ,expected", type_hints) +def test_type_hints_class_properties(typ, expected): + class Foo: + """Short description\n + Attributes + ---------- + a + """ + @property + def a(self) -> typ: ... + + doc_foo = ClassDoc(Foo) + assert doc_foo["Attributes"][0].type == expected + + @pytest.mark.parametrize("typ,expected", type_hints) def test_type_hints_class_methods(typ, expected): class Foo: From 890f9dfd752e27d2c39851ae11b62d29ee124243 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Fri, 16 May 2025 20:04:54 +0100 Subject: [PATCH 12/14] Alter _find_type_hint for better type inference The _find_type_hint method now provides better type inference for class attributes without type hints at the cost of less useful types for the Methods section. However, that section does not seem to be using the types in the final Sphinx render and it is not clear that types even make sense there, so it might be a reasonable trade-off. --- numpydoc/docscrape.py | 11 ++++------- numpydoc/tests/test_docscrape.py | 31 ++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index f1131047..79901901 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -816,17 +816,14 @@ def _find_type_hint(cls: type, arg_name: str) -> str: except AttributeError: return "" - attr = attr.fget if isinstance(attr, property) else attr - - if callable(attr): + if isinstance(attr, property): try: - signature = inspect.signature(attr) + signature = inspect.signature(attr.fget) except ValueError: return "" - return _annotation_to_string(signature.return_annotation) - else: - return type(attr).__name__ + + return type(attr).__name__ if type(annotation) == type: return str(annotation.__name__) diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index f3108ee7..abc7263e 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1718,6 +1718,10 @@ class MyFooWithParams(foo): T = typing.TypeVar("T") + +class CustomTypeClass: ... + + type_hints = [ (None, "None"), (int, "int"), @@ -1755,6 +1759,7 @@ class MyFooWithParams(foo): typing.Callable[[], typing.NoReturn], "help='description'"], "typing.Annotated[dict[str, dict[str, list[typing.Union[float, tuple[int, complex]]]]], " "typing.Callable[[], typing.NoReturn], \"help='description'\"]"), + (CustomTypeClass, "CustomTypeClass") ] @pytest.mark.parametrize("typ,expected", type_hints) @@ -1846,8 +1851,8 @@ def a(self) -> typ: ... assert doc_foo["Attributes"][0].type == expected -@pytest.mark.parametrize("typ,expected", type_hints) -def test_type_hints_class_methods(typ, expected): +@pytest.mark.parametrize("typ,_", type_hints) +def test_type_hints_class_methods(typ, _): class Foo: """Short description\n Methods @@ -1859,7 +1864,7 @@ class Foo: def a(self) -> typ: ... doc = ClassDoc(Foo) - assert doc["Methods"][0].type == expected + assert doc["Methods"][0].type == "function" def test_type_hints_args_kwargs(): @@ -1970,6 +1975,26 @@ class Bar: assert doc["Attributes"][0].type == "" +@pytest.mark.parametrize( + "value,expected", + ((42, "int"), (4.2, "float"), ("string", "str"), (True, "bool"), (None, "NoneType"), + ([1, 2, 3], "list"), ({'a': 42}, "dict"), (CustomTypeClass(), "CustomTypeClass"), + (CustomTypeClass, "type")) +) +def test_type_hints_implied_from_class_attribute(value, expected): + class Foo: + """Short description\n + Attributes + ---------- + a + Description for a. + """ + a = value + + doc = ClassDoc(Foo) + assert doc["Attributes"][0].type == expected + + if __name__ == "__main__": import pytest From 456bd85856ef3e02512d8c35db5466236c8045b9 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Mon, 19 May 2025 09:17:08 +0100 Subject: [PATCH 13/14] Fix ruff errors --- numpydoc/docscrape.py | 10 +++++----- numpydoc/tests/test_docscrape.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 79901901..e37c9ec8 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -806,13 +806,13 @@ def _handle_combined_attributes(self, arg_names: str, cls: type): return hint1 @staticmethod - def _find_type_hint(cls: type, arg_name: str) -> str: - type_hints = get_type_hints(cls, include_extras=True) + def _find_type_hint(obj: type, arg_name: str) -> str: + type_hints = get_type_hints(obj, include_extras=True) try: annotation = type_hints[arg_name] except KeyError: try: - attr = getattr(cls, arg_name) + attr = getattr(obj, arg_name) except AttributeError: return "" @@ -825,7 +825,7 @@ def _find_type_hint(cls: type, arg_name: str) -> str: return type(attr).__name__ - if type(annotation) == type: + if type(annotation) is type: return str(annotation.__name__) else: return str(annotation) @@ -834,7 +834,7 @@ def _find_type_hint(cls: type, arg_name: str) -> str: def _annotation_to_string(annotation) -> str: if annotation == inspect.Signature.empty: return "" - elif type(annotation) == type: + elif type(annotation) is type: return str(annotation.__name__) else: return str(annotation) diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index abc7263e..c00f8375 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1851,8 +1851,8 @@ def a(self) -> typ: ... assert doc_foo["Attributes"][0].type == expected -@pytest.mark.parametrize("typ,_", type_hints) -def test_type_hints_class_methods(typ, _): +@pytest.mark.parametrize("typ,__", type_hints) +def test_type_hints_class_methods(typ, __): class Foo: """Short description\n Methods From a3fc432c7e3cd0ec866bf9e5c28250a48e04b4f5 Mon Sep 17 00:00:00 2001 From: Rastislav Turanyi Date: Mon, 19 May 2025 09:51:08 +0100 Subject: [PATCH 14/14] Replace forgotten type return with _annotation_to_string --- numpydoc/docscrape.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index e37c9ec8..6315e164 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -825,10 +825,7 @@ def _find_type_hint(obj: type, arg_name: str) -> str: return type(attr).__name__ - if type(annotation) is type: - return str(annotation.__name__) - else: - return str(annotation) + return _annotation_to_string(annotation) def _annotation_to_string(annotation) -> str: