Skip to content

Commit c3d0a79

Browse files
authored
Docstrings for classes and properties for extension modules (#2)
* Docstrings for class properties * Docstrings for classes * Validates the include-docstrings arg to generate docstrings * Fixing test template
1 parent 3b00002 commit c3d0a79

File tree

4 files changed

+192
-39
lines changed

4 files changed

+192
-39
lines changed

mypy/stubdoc.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,63 @@ def __eq__(self, other: Any) -> bool:
7272
return False
7373

7474

75+
class ClassSig(NamedTuple):
76+
name: str
77+
base_types: list[str] | None = None
78+
79+
def format_sig(
80+
self,
81+
indent: str,
82+
types: list[str],
83+
methods: list[str],
84+
static_properties: list[str],
85+
rw_properties: list[str],
86+
ro_properties: list[str],
87+
docstring: str | None = None,
88+
) -> list[str]:
89+
90+
output: list[str] = []
91+
92+
if self.base_types:
93+
bases_str = "(%s)" % ", ".join(self.base_types)
94+
else:
95+
bases_str = ""
96+
97+
if docstring:
98+
sufix = f"\n{indent} {mypy.util.quote_docstring(docstring)}\n"
99+
else:
100+
sufix = ""
101+
102+
if types or static_properties or rw_properties or methods or ro_properties:
103+
sig = f"{indent}class {self.name}{bases_str}:"
104+
output.append(f"{sig}{sufix}")
105+
106+
for line in types:
107+
if (
108+
output
109+
and output[-1]
110+
and not output[-1].strip().startswith("class")
111+
and line.strip().startswith("class")
112+
):
113+
output.append("")
114+
115+
output.append(line)
116+
117+
for line in static_properties:
118+
output.append(line)
119+
for line in rw_properties:
120+
output.append(line)
121+
for line in methods:
122+
output.append(line)
123+
for line in ro_properties:
124+
output.append(line)
125+
else:
126+
sig = f"{indent}class {self.name}{bases_str}: ..."
127+
output.append(f"{sig}{sufix}")
128+
129+
return output
130+
131+
75132
class FunctionSig(NamedTuple):
76133
name: str
77134
args: list[ArgSig]
@@ -150,6 +207,39 @@ def format_sig(
150207
return f"{sig}{suffix}"
151208

152209

210+
class PropertySig(NamedTuple):
211+
name: str
212+
prop_type: str
213+
214+
def format_sig(
215+
self,
216+
indent: str = "",
217+
is_readonly: bool | None = False,
218+
is_static: bool | None = False,
219+
name_ref: str | None = None,
220+
docstring: str | None = None,
221+
) -> str:
222+
223+
if is_static:
224+
if docstring:
225+
sufix = f"\n{indent}{mypy.util.quote_docstring(docstring)}"
226+
else:
227+
sufix = ""
228+
229+
trailing_comment = " # read-only" if is_readonly else ""
230+
sig = f"{indent}{self.name}: {name_ref}[{self.prop_type}] = ...{trailing_comment}"
231+
232+
return f"{sig}{sufix}"
233+
else:
234+
sig = f"{indent}{self.name}: {self.prop_type}"
235+
if docstring:
236+
sufix = f"\n{indent}{mypy.util.quote_docstring(docstring)}"
237+
else:
238+
sufix = ""
239+
240+
return f"{sig}{sufix}"
241+
242+
153243
# States of the docstring parser.
154244
STATE_INIT: Final = 1
155245
STATE_FUNCTION_NAME: Final = 2

mypy/stubgenc.py

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
from mypy.moduleinspect import is_c_module
1919
from mypy.stubdoc import (
2020
ArgSig,
21+
ClassSig,
2122
FunctionSig,
23+
PropertySig,
2224
Sig,
2325
find_unique_signatures,
2426
infer_arg_sig_from_anon_docstring,
@@ -649,16 +651,23 @@ def generate_function_stub(
649651
output.extend(self.format_func_def(inferred, decorators=decorators, docstring=docstring))
650652
self._fix_iter(ctx, inferred, output)
651653

652-
def _indent_docstring(self, docstring: str) -> str:
654+
def _indent_docstring(
655+
self, docstring: str, extra_indent: bool = True, trailing_newline: bool = False
656+
) -> str:
653657
"""Fix indentation of docstring extracted from pybind11 or other binding generators."""
654658
lines = docstring.splitlines(keepends=True)
655-
indent = self._indent + " "
659+
indent = self._indent + (" " if extra_indent else "")
656660
if len(lines) > 1:
657661
if not all(line.startswith(indent) or not line.strip() for line in lines):
658662
# if the docstring is not indented, then indent all but the first line
659663
for i, line in enumerate(lines[1:]):
660664
if line.strip():
661-
lines[i + 1] = indent + line
665+
# ignore any left space to keep the standard ident
666+
lines[i + 1] = indent + line.lstrip()
667+
668+
if trailing_newline and not lines[-1].endswith("\n"):
669+
lines[-1] += "\n"
670+
662671
# if there's a trailing newline, add a final line to visually indent the quoted docstring
663672
if lines[-1].endswith("\n"):
664673
if len(lines) > 1:
@@ -728,6 +737,13 @@ def generate_property_stub(
728737
self.record_name(ctx.name)
729738
static = self.is_static_property(raw_obj)
730739
readonly = self.is_property_readonly(raw_obj)
740+
741+
if docstring:
742+
# fields must define its docstring using the same ident
743+
# readonly properties generates a function,
744+
# which requires an extra ident in the first line
745+
docstring = self._indent_docstring(docstring, extra_indent=readonly)
746+
731747
if static:
732748
ret_type: str | None = self.strip_or_import(self.get_type_annotation(obj))
733749
else:
@@ -738,25 +754,35 @@ def generate_property_stub(
738754
if inferred_type is not None:
739755
inferred_type = self.strip_or_import(inferred_type)
740756

757+
if not self._include_docstrings:
758+
docstring = None
759+
741760
if static:
742761
classvar = self.add_name("typing.ClassVar")
743-
trailing_comment = " # read-only" if readonly else ""
744762
if inferred_type is None:
745763
inferred_type = self.add_name("_typeshed.Incomplete")
746764

765+
prop_sig = PropertySig(name, inferred_type)
747766
static_properties.append(
748-
f"{self._indent}{name}: {classvar}[{inferred_type}] = ...{trailing_comment}"
767+
prop_sig.format_sig(
768+
indent=self._indent,
769+
is_readonly=readonly,
770+
is_static=True,
771+
name_ref=classvar,
772+
docstring=docstring,
773+
)
749774
)
750775
else: # regular property
751776
if readonly:
752777
ro_properties.append(f"{self._indent}@property")
753-
sig = FunctionSig(name, [ArgSig("self")], inferred_type)
754-
ro_properties.append(sig.format_sig(indent=self._indent))
778+
func_sig = FunctionSig(name, [ArgSig("self")], inferred_type)
779+
ro_properties.append(func_sig.format_sig(indent=self._indent, docstring=docstring))
755780
else:
756781
if inferred_type is None:
757782
inferred_type = self.add_name("_typeshed.Incomplete")
758783

759-
rw_properties.append(f"{self._indent}{name}: {inferred_type}")
784+
prop_sig = PropertySig(name, inferred_type)
785+
rw_properties.append(prop_sig.format_sig(indent=self._indent, docstring=docstring))
760786

761787
def get_type_fullname(self, typ: type) -> str:
762788
"""Given a type, return a string representation"""
@@ -859,34 +885,27 @@ def generate_class_stub(
859885
classvar = self.add_name("typing.ClassVar")
860886
static_properties.append(f"{self._indent}{attr}: {classvar}[{prop_type_name}] = ...")
861887

888+
docstring = class_info.docstring if self._include_docstrings else None
889+
if docstring:
890+
docstring = self._indent_docstring(
891+
docstring, extra_indent=False, trailing_newline=True
892+
)
893+
862894
self.dedent()
863895

864896
bases = self.get_base_types(cls)
865-
if bases:
866-
bases_str = "(%s)" % ", ".join(bases)
867-
else:
868-
bases_str = ""
869-
if types or static_properties or rw_properties or methods or ro_properties:
870-
output.append(f"{self._indent}class {class_name}{bases_str}:")
871-
for line in types:
872-
if (
873-
output
874-
and output[-1]
875-
and not output[-1].strip().startswith("class")
876-
and line.strip().startswith("class")
877-
):
878-
output.append("")
879-
output.append(line)
880-
for line in static_properties:
881-
output.append(line)
882-
for line in rw_properties:
883-
output.append(line)
884-
for line in methods:
885-
output.append(line)
886-
for line in ro_properties:
887-
output.append(line)
888-
else:
889-
output.append(f"{self._indent}class {class_name}{bases_str}: ...")
897+
sig = ClassSig(class_name, bases)
898+
output.extend(
899+
sig.format_sig(
900+
indent=self._indent,
901+
types=types,
902+
methods=methods,
903+
static_properties=static_properties,
904+
rw_properties=rw_properties,
905+
ro_properties=ro_properties,
906+
docstring=docstring,
907+
)
908+
)
890909

891910
def generate_variable_stub(self, name: str, obj: object, output: list[str]) -> None:
892911
"""Generate stub for a single variable using runtime introspection.

test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/__init__.pyi

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,18 @@ class StaticMethods:
3434

3535
class TestStruct:
3636
field_readwrite: int
37+
"""(self: pybind11_fixtures.TestStruct) -> int"""
3738
field_readwrite_docstring: int
39+
"""some docstring
40+
(self: pybind11_fixtures.TestStruct) -> int
41+
"""
3842
def __init__(self, *args, **kwargs) -> None:
3943
"""Initialize self. See help(type(self)) for accurate signature."""
4044
@property
41-
def field_readonly(self) -> int: ...
45+
def field_readonly(self) -> int:
46+
"""some docstring
47+
(arg0: pybind11_fixtures.TestStruct) -> int
48+
"""
4249

4350
def func_incomplete_signature(*args, **kwargs):
4451
"""func_incomplete_signature() -> dummy_sub_namespace::HasNoBinding"""

test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/demo.pyi

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ __version__: str
55

66
class Point:
77
class AngleUnit:
8+
"""Members:
9+
10+
radian
11+
12+
degree
13+
"""
14+
815
__members__: ClassVar[dict] = ... # read-only
16+
"""__members__(arg0: handle) -> dict"""
917
__entries: ClassVar[dict] = ...
1018
degree: ClassVar[Point.AngleUnit] = ...
1119
radian: ClassVar[Point.AngleUnit] = ...
@@ -22,12 +30,27 @@ class Point:
2230
def __ne__(self, other: object) -> bool:
2331
"""__ne__(self: object, other: object) -> bool"""
2432
@property
25-
def name(self) -> str: ...
33+
def name(self) -> str:
34+
"""name(self: handle) -> str
35+
36+
name(self: handle) -> str
37+
"""
2638
@property
27-
def value(self) -> int: ...
39+
def value(self) -> int:
40+
"""(arg0: pybind11_fixtures.demo.Point.AngleUnit) -> int"""
2841

2942
class LengthUnit:
43+
"""Members:
44+
45+
mm
46+
47+
pixel
48+
49+
inch
50+
"""
51+
3052
__members__: ClassVar[dict] = ... # read-only
53+
"""__members__(arg0: handle) -> dict"""
3154
__entries: ClassVar[dict] = ...
3255
inch: ClassVar[Point.LengthUnit] = ...
3356
mm: ClassVar[Point.LengthUnit] = ...
@@ -45,16 +68,29 @@ class Point:
4568
def __ne__(self, other: object) -> bool:
4669
"""__ne__(self: object, other: object) -> bool"""
4770
@property
48-
def name(self) -> str: ...
71+
def name(self) -> str:
72+
"""name(self: handle) -> str
73+
74+
name(self: handle) -> str
75+
"""
4976
@property
50-
def value(self) -> int: ...
77+
def value(self) -> int:
78+
"""(arg0: pybind11_fixtures.demo.Point.LengthUnit) -> int"""
5179
angle_unit: ClassVar[Point.AngleUnit] = ...
80+
"""(arg0: object) -> pybind11_fixtures.demo.Point.AngleUnit"""
5281
length_unit: ClassVar[Point.LengthUnit] = ...
82+
"""(arg0: object) -> pybind11_fixtures.demo.Point.LengthUnit"""
5383
x_axis: ClassVar[Point] = ... # read-only
84+
"""(arg0: object) -> pybind11_fixtures.demo.Point"""
5485
y_axis: ClassVar[Point] = ... # read-only
86+
"""(arg0: object) -> pybind11_fixtures.demo.Point"""
5587
origin: ClassVar[Point] = ...
5688
x: float
89+
"""some docstring
90+
(self: pybind11_fixtures.demo.Point) -> float
91+
"""
5792
y: float
93+
"""(arg0: pybind11_fixtures.demo.Point) -> float"""
5894
@overload
5995
def __init__(self) -> None:
6096
"""__init__(*args, **kwargs)
@@ -94,7 +130,8 @@ class Point:
94130
2. distance_to(self: pybind11_fixtures.demo.Point, other: pybind11_fixtures.demo.Point) -> float
95131
"""
96132
@property
97-
def length(self) -> float: ...
133+
def length(self) -> float:
134+
"""(arg0: pybind11_fixtures.demo.Point) -> float"""
98135

99136
def answer() -> int:
100137
'''answer() -> int

0 commit comments

Comments
 (0)