Repository: python/mypy

Issue:
Improve stubgenc type detection on PyBind11 properties
Hello,

Thank you for the work done here on mypy. I don't know much about it yet but I believe the following would improve stub files generation for [Pybind11](https://pybind11.readthedocs.io/en/stable/) users.

## Current behavior
PyBind11 has the ability to bind C++ classes to Python objects.
While `stubgen` properly detects types for most functions/methods from the docstrings, types in properties (get, set) created from PyBind11, are reported as `Any`.

For instance, the following C++ code defines a class `Dog` and bind it to Python in a module named `python_example`.
```cpp
#include <pybind11/pybind11.h>

struct Dog {
  int age = 0;
  int n_friends = 0;
};

PYBIND11_MODULE(python_example, m) {
  pybind11::class_<Dog>(m, "Dog")
    .def(pybind11::init<>())  // Bind an __init__ method
    .def("get_age", [](Dog const& self){return self.age; })  // Bind a get_age method
    .def_readonly("age", &Dog::age)  // Bind a readonly property
    .def_readwrite("n_friends", &Dog::n_friends); // Bind a read and write property
}
```

Running `stubgen -m python_example` generates the following stub file
```python
from typing import Any

class Dog:
    def __init__(self) -> None: ...
    def get_age(self) -> int: ...
    @property
    def age(self) -> Any: ...
    @property
    def n_friends(self) -> Any: ...
    @n_friends.setter
    def n_friends(self, val: Any) -> None: ...
```
We can seed that while `stubgen` properly detect type for the `get_name` method, all properties are being created with `Any`.

## Expected behavior
It would be preferable if `stubgen` generated the following stub file
```python
from typing import Any

class Dog:
    def __init__(self) -> None: ...
    def get_age(self) -> int: ...
    @property
    def age(self) -> int: ...
    @property
    def n_friends(self) -> int: ...
    @n_friends.setter
    def n_friends(self, val: int) -> None: ...
```

## Docstring information
Pybind11 sets the following information in the docstrings
```python
In [1]: import python_example

In [2]: python_example.Dog.get_age.__doc__
Out[2]: 'get_age(self: python_example.Dog) -> int\n'

In [3]: python_example.Dog.age.__doc__
Out[3]: ''

In [4]: python_example.Dog.age.fget.__doc__
Out[4]: '(self: python_example.Dog) -> int\n'

In [5]: python_example.Dog.age.fset.__doc__

In [6]: python_example.Dog.n_friends.fget.__doc__
Out[6]: '(self: python_example.Dog) -> int\n'

In [7]: python_example.Dog.n_friends.fset.__doc__
Out[7]: '(self: python_example.Dog, arg0: int) -> None\n'

```

## Technical information
* mypy version: mypy 0.790+dev. 068594eb38fc550d834b7e61745177c423f2b224
* Python 3.8.2
* MacOs 10.15.5
* Compiler Apple clang version 11.0.3 (clang-1103.0.32.62) Target: x86_64-apple-darwin19.5.0


Discussion:
I think we will accept a PR for this if someone volunteers.

Resolving patch:
diff --git a/mypy/stubdoc.py b/mypy/stubdoc.py
--- a/mypy/stubdoc.py
+++ b/mypy/stubdoc.py
@@ -239,7 +239,7 @@ def is_unique_args(sig: FunctionSig) -> bool:
     return [sig for sig in sigs if is_unique_args(sig)]
 
 
-def infer_arg_sig_from_docstring(docstr: str) -> List[ArgSig]:
+def infer_arg_sig_from_anon_docstring(docstr: str) -> List[ArgSig]:
     """Convert signature in form of "(self: TestClass, arg0: str='ada')" to List[TypedArgList]."""
     ret = infer_sig_from_docstring("stub" + docstr, "stub")
     if ret:
@@ -247,6 +247,14 @@ def infer_arg_sig_from_docstring(docstr: str) -> List[ArgSig]:
     return []
 
 
+def infer_ret_type_sig_from_anon_docstring(docstr: str) -> Optional[str]:
+    """Convert signature in form of "(self: TestClass, arg0) -> int" to their return type."""
+    ret = infer_sig_from_docstring("stub" + docstr.strip(), "stub")
+    if ret:
+        return ret[0].ret_type
+    return None
+
+
 def parse_signature(sig: str) -> Optional[Tuple[str,
                                                 List[str],
                                                 List[str]]]:
diff --git a/mypy/stubgenc.py b/mypy/stubgenc.py
--- a/mypy/stubgenc.py
+++ b/mypy/stubgenc.py
@@ -14,7 +14,7 @@
 from mypy.moduleinspect import is_c_module
 from mypy.stubdoc import (
     infer_sig_from_docstring, infer_prop_type_from_docstring, ArgSig,
-    infer_arg_sig_from_docstring, FunctionSig
+    infer_arg_sig_from_anon_docstring, infer_ret_type_sig_from_anon_docstring, FunctionSig
 )
 
 
@@ -144,7 +144,7 @@ def generate_c_function_stub(module: ModuleType,
     if (name in ('__new__', '__init__') and name not in sigs and class_name and
             class_name in class_sigs):
         inferred = [FunctionSig(name=name,
-                                args=infer_arg_sig_from_docstring(class_sigs[class_name]),
+                                args=infer_arg_sig_from_anon_docstring(class_sigs[class_name]),
                                 ret_type=ret_type)]  # type: Optional[List[FunctionSig]]
     else:
         docstr = getattr(obj, '__doc__', None)
@@ -154,7 +154,7 @@ def generate_c_function_stub(module: ModuleType,
                 inferred = [FunctionSig(name, args=infer_method_sig(name), ret_type=ret_type)]
             else:
                 inferred = [FunctionSig(name=name,
-                                        args=infer_arg_sig_from_docstring(
+                                        args=infer_arg_sig_from_anon_docstring(
                                             sigs.get(name, '(*args, **kwargs)')),
                                         ret_type=ret_type)]
 
@@ -217,8 +217,20 @@ def generate_c_property_stub(name: str, obj: object, output: List[str], readonly
 
     Try to infer type from docstring, append resulting lines to 'output'.
     """
-    docstr = getattr(obj, '__doc__', None)
-    inferred = infer_prop_type_from_docstring(docstr)
+    def infer_prop_type(docstr: Optional[str]) -> Optional[str]:
+        """Infer property type from docstring or docstring signature."""
+        if docstr is not None:
+            inferred = infer_ret_type_sig_from_anon_docstring(docstr)
+            if not inferred:
+                inferred = infer_prop_type_from_docstring(docstr)
+            return inferred
+        else:
+            return None
+
+    inferred = infer_prop_type(getattr(obj, '__doc__', None))
+    if not inferred:
+        fget = getattr(obj, 'fget', None)
+        inferred = infer_prop_type(getattr(fget, '__doc__', None))
     if not inferred:
         inferred = 'Any'
 


Test patch:
diff --git a/mypy/test/teststubgen.py b/mypy/test/teststubgen.py
--- a/mypy/test/teststubgen.py
+++ b/mypy/test/teststubgen.py
@@ -19,11 +19,13 @@
     mypy_options, is_blacklisted_path, is_non_library_module
 )
 from mypy.stubutil import walk_packages, remove_misplaced_type_comments, common_dir_prefix
-from mypy.stubgenc import generate_c_type_stub, infer_method_sig, generate_c_function_stub
+from mypy.stubgenc import (
+    generate_c_type_stub, infer_method_sig, generate_c_function_stub, generate_c_property_stub
+)
 from mypy.stubdoc import (
     parse_signature, parse_all_signatures, build_signature, find_unique_signatures,
     infer_sig_from_docstring, infer_prop_type_from_docstring, FunctionSig, ArgSig,
-    infer_arg_sig_from_docstring, is_valid_type
+    infer_arg_sig_from_anon_docstring, is_valid_type
 )
 from mypy.moduleinspect import ModuleInspect, InspectError
 
@@ -270,12 +272,12 @@ def test_infer_sig_from_docstring_bad_indentation(self) -> None:
              x
             """, 'func'), None)
 
-    def test_infer_arg_sig_from_docstring(self) -> None:
-        assert_equal(infer_arg_sig_from_docstring("(*args, **kwargs)"),
+    def test_infer_arg_sig_from_anon_docstring(self) -> None:
+        assert_equal(infer_arg_sig_from_anon_docstring("(*args, **kwargs)"),
                      [ArgSig(name='*args'), ArgSig(name='**kwargs')])
 
         assert_equal(
-            infer_arg_sig_from_docstring(
+            infer_arg_sig_from_anon_docstring(
                 "(x: Tuple[int, Tuple[str, int], str]=(1, ('a', 2), 'y'), y: int=4)"),
             [ArgSig(name='x', type='Tuple[int,Tuple[str,int],str]', default=True),
              ArgSig(name='y', type='int', default=True)])
@@ -778,6 +780,20 @@ def test(arg0: str) -> None:
         assert_equal(output, ['def test(arg0: str) -> Action: ...'])
         assert_equal(imports, [])
 
+    def test_generate_c_property_with_pybind11(self) -> None:
+        """Signatures included by PyBind11 inside property.fget are read."""
+        class TestClass:
+            def get_attribute(self) -> None:
+                """
+                (self: TestClass) -> str
+                """
+                pass
+            attribute = property(get_attribute, doc="")
+
+        output = []  # type: List[str]
+        generate_c_property_stub('attribute', TestClass.attribute, output, readonly=True)
+        assert_equal(output, ['@property', 'def attribute(self) -> str: ...'])
+
     def test_generate_c_type_with_overload_pybind11(self) -> None:
         class TestClass:
             def __init__(self, arg0: str) -> None: