diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index bfc2840e..6315e164 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 get_type_hints from warnings import warn @@ -237,7 +238,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 +249,28 @@ def _parse_param_list(self, content, single_element_is_type=False): return params + 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. # # @@ -577,6 +601,11 @@ def dedent_lines(lines): class FunctionDoc(NumpyDocString): def __init__(self, func, role="func", doc=None, config=None): self._f = func + try: + self._signature = inspect.signature(func) + except ValueError: + self._signature = None + self._role = role # e.g. "func" or "meth" if doc is None: @@ -610,6 +639,20 @@ def __str__(self): out += super().__str__(func_role=self._role) return out + 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: + parameter = self._handle_combined_parameters(arg_name, self._signature.parameters) + if parameter is None: + return "" + + return _annotation_to_string(parameter.annotation) + class ObjDoc(NumpyDocString): def __init__(self, obj, doc=None, config=None): @@ -645,6 +688,14 @@ 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: + try: + self._signature = inspect.signature(cls.__init__) + except ValueError: + self._signature = None + else: + self._signature = None + NumpyDocString.__init__(self, doc) _members = config.get("members", []) @@ -728,6 +779,63 @@ def _is_show_member(self, name): or name in self._cls.__dict__ ) + 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: + 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_attributes(self, arg_names: str, cls: type): + arg_names = arg_names.split(',') + 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.strip()) + + if hint != hint1: + return "" + + return hint1 + + @staticmethod + 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(obj, arg_name) + except AttributeError: + return "" + + if isinstance(attr, property): + try: + signature = inspect.signature(attr.fget) + except ValueError: + return "" + return _annotation_to_string(signature.return_annotation) + + return type(attr).__name__ + + return _annotation_to_string(annotation) + + +def _annotation_to_string(annotation) -> str: + if annotation == inspect.Signature.empty: + return "" + elif type(annotation) is type: + return str(annotation.__name__) + else: + return str(annotation) + def get_doc_object( obj, diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 4cafc762..c00f8375 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,285 @@ class MyFooWithParams(foo): assert sds["Parameters"][1].desc[0] == "The baz attribute" +T = typing.TypeVar("T") + + +class CustomTypeClass: ... + + +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'\"]"), + (CustomTypeClass, "CustomTypeClass") +] + +@pytest.mark.parametrize("typ,expected", type_hints) +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 + + +@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 + + +@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,__", type_hints) +def test_type_hints_class_methods(typ, __): + class Foo: + """Short description\n + Methods + ------- + a + Description for a. + """ + + def a(self) -> typ: ... + + doc = ClassDoc(Foo) + assert doc["Methods"][0].type == "function" + + +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" + + +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 == "" + + +@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