diff --git a/mypy/stubgenc.py b/mypy/stubgenc.py index 7ab500b4fe120..e16416c5e358b 100755 --- a/mypy/stubgenc.py +++ b/mypy/stubgenc.py @@ -11,9 +11,11 @@ import inspect import keyword import os.path +import sys from types import FunctionType, ModuleType from typing import Any, Callable, Mapping +import mypy.util from mypy.fastparse import parse_type_comment from mypy.moduleinspect import is_c_module from mypy.stubdoc import ( @@ -651,21 +653,35 @@ def generate_function_stub( def _indent_docstring(self, docstring: str) -> str: """Fix indentation of docstring extracted from pybind11 or other binding generators.""" - lines = docstring.splitlines(keepends=True) - indent = self._indent + " " - if len(lines) > 1: - if not all(line.startswith(indent) or not line.strip() for line in lines): - # if the docstring is not indented, then indent all but the first line - for i, line in enumerate(lines[1:]): - if line.strip(): - lines[i + 1] = indent + line - # if there's a trailing newline, add a final line to visually indent the quoted docstring - if lines[-1].endswith("\n"): - if len(lines) > 1: - lines.append(indent) - else: - lines[-1] = lines[-1][:-1] - return "".join(lines) + # this follows inspect.cleandoc except it only changes the margins. + # it won't remove empty lines at the start or end. + # nor remove whitespace at the start of the first line. + # essentially it should do as little to the docstring as possible. + + lines = docstring.expandtabs().split("\n") + + # Find minimum indentation of any non-blank lines after first line. + margin = sys.maxsize + for line in lines[1:]: + content = len(line.lstrip(" ")) + if content: + indent = len(line) - content + margin = min(margin, indent) + + doc_indent = self._indent + " " + # Remove margin and set it to indent. + if margin < sys.maxsize: + for i in range(1, len(lines)): + # dedent the line + line = lines[i][margin:] + # if the line after dedent was not empty, prepend our indent + if line: + line = doc_indent + line + lines[i] = line + if lines[-1] == "": + # if the last line was empty, indent it so the triple end quote is in a good spot. + lines[-1] = doc_indent + lines[-1] + return "\n".join(lines) def _fix_iter( self, ctx: FunctionContext, inferred: list[FunctionSig], output: list[str] @@ -809,6 +825,7 @@ def generate_class_stub(self, class_name: str, cls: type, output: list[str]) -> self.indent() class_info = ClassInfo(class_name, "", getattr(cls, "__doc__", None), cls) + docstring = class_info.docstring if self._include_docstrings else None for attr, value in items: # use unevaluated descriptors when dealing with property inspection @@ -857,13 +874,18 @@ def generate_class_stub(self, class_name: str, cls: type, output: list[str]) -> self.dedent() + if docstring: + docstring = self._indent_docstring(docstring) + bases = self.get_base_types(cls) if bases: bases_str = "(%s)" % ", ".join(bases) else: bases_str = "" - if types or static_properties or rw_properties or methods or ro_properties: + if types or static_properties or rw_properties or methods or ro_properties or docstring: output.append(f"{self._indent}class {class_name}{bases_str}:") + if docstring: + output.append(f"{self._indent} {mypy.util.quote_docstring(docstring)}") for line in types: if ( output diff --git a/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/__init__.pyi b/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/__init__.pyi index db04bccab028f..1f80e14eda804 100644 --- a/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/__init__.pyi +++ b/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/__init__.pyi @@ -41,12 +41,17 @@ class TestStruct: def field_readonly(self) -> int: ... def func_incomplete_signature(*args, **kwargs): - """func_incomplete_signature() -> dummy_sub_namespace::HasNoBinding""" + """func_incomplete_signature() -> dummy_sub_namespace::HasNoBinding + """ def func_returning_optional() -> int | None: - """func_returning_optional() -> Optional[int]""" + """func_returning_optional() -> Optional[int] + """ def func_returning_pair() -> tuple[int, float]: - """func_returning_pair() -> Tuple[int, float]""" + """func_returning_pair() -> Tuple[int, float] + """ def func_returning_path() -> os.PathLike: - """func_returning_path() -> os.PathLike""" + """func_returning_path() -> os.PathLike + """ def func_returning_vector() -> list[float]: - """func_returning_vector() -> List[float]""" + """func_returning_vector() -> List[float] + """ diff --git a/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/demo.pyi b/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/demo.pyi index 1be0bc905a439..47ea1e50b290d 100644 --- a/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/demo.pyi +++ b/test-data/pybind11_fixtures/expected_stubs_with_docs/pybind11_fixtures/demo.pyi @@ -5,45 +5,73 @@ __version__: str class Point: class AngleUnit: + """Describes the angle measurement units. + + Members: + + radian + + degree""" __members__: ClassVar[dict] = ... # read-only __entries: ClassVar[dict] = ... degree: ClassVar[Point.AngleUnit] = ... radian: ClassVar[Point.AngleUnit] = ... def __init__(self, value: int) -> None: - """__init__(self: pybind11_fixtures.demo.Point.AngleUnit, value: int) -> None""" + """__init__(self: pybind11_fixtures.demo.Point.AngleUnit, value: int) -> None + """ def __eq__(self, other: object) -> bool: - """__eq__(self: object, other: object) -> bool""" + """__eq__(self: object, other: object) -> bool + """ def __hash__(self) -> int: - """__hash__(self: object) -> int""" + """__hash__(self: object) -> int + """ def __index__(self) -> int: - """__index__(self: pybind11_fixtures.demo.Point.AngleUnit) -> int""" + """__index__(self: pybind11_fixtures.demo.Point.AngleUnit) -> int + """ def __int__(self) -> int: - """__int__(self: pybind11_fixtures.demo.Point.AngleUnit) -> int""" + """__int__(self: pybind11_fixtures.demo.Point.AngleUnit) -> int + """ def __ne__(self, other: object) -> bool: - """__ne__(self: object, other: object) -> bool""" + """__ne__(self: object, other: object) -> bool + """ @property def name(self) -> str: ... @property def value(self) -> int: ... class LengthUnit: + """Describes the length measurement units. + + Members: + + mm + + pixel + + inch""" __members__: ClassVar[dict] = ... # read-only __entries: ClassVar[dict] = ... inch: ClassVar[Point.LengthUnit] = ... mm: ClassVar[Point.LengthUnit] = ... pixel: ClassVar[Point.LengthUnit] = ... def __init__(self, value: int) -> None: - """__init__(self: pybind11_fixtures.demo.Point.LengthUnit, value: int) -> None""" + """__init__(self: pybind11_fixtures.demo.Point.LengthUnit, value: int) -> None + """ def __eq__(self, other: object) -> bool: - """__eq__(self: object, other: object) -> bool""" + """__eq__(self: object, other: object) -> bool + """ def __hash__(self) -> int: - """__hash__(self: object) -> int""" + """__hash__(self: object) -> int + """ def __index__(self) -> int: - """__index__(self: pybind11_fixtures.demo.Point.LengthUnit) -> int""" + """__index__(self: pybind11_fixtures.demo.Point.LengthUnit) -> int + """ def __int__(self) -> int: - """__int__(self: pybind11_fixtures.demo.Point.LengthUnit) -> int""" + """__int__(self: pybind11_fixtures.demo.Point.LengthUnit) -> int + """ def __ne__(self, other: object) -> bool: - """__ne__(self: object, other: object) -> bool""" + """__ne__(self: object, other: object) -> bool + """ @property def name(self) -> str: ... @property @@ -74,7 +102,8 @@ class Point: 2. __init__(self: pybind11_fixtures.demo.Point, x: float, y: float) -> None """ def as_list(self) -> list[float]: - """as_list(self: pybind11_fixtures.demo.Point) -> List[float]""" + """as_list(self: pybind11_fixtures.demo.Point) -> List[float] + """ @overload def distance_to(self, x: float, y: float) -> float: """distance_to(*args, **kwargs) @@ -102,11 +131,13 @@ def answer() -> int: answer docstring, with end quote" ''' def midpoint(left: float, right: float) -> float: - """midpoint(left: float, right: float) -> float""" + """midpoint(left: float, right: float) -> float + """ def sum(arg0: int, arg1: int) -> int: '''sum(arg0: int, arg1: int) -> int multiline docstring test, edge case quotes """\'\'\' ''' def weighted_midpoint(left: float, right: float, alpha: float = ...) -> float: - """weighted_midpoint(left: float, right: float, alpha: float = 0.5) -> float""" + """weighted_midpoint(left: float, right: float, alpha: float = 0.5) -> float + """ diff --git a/test-data/pybind11_fixtures/src/main.cpp b/test-data/pybind11_fixtures/src/main.cpp index 4d275ab1fd709..3d947778f86fa 100644 --- a/test-data/pybind11_fixtures/src/main.cpp +++ b/test-data/pybind11_fixtures/src/main.cpp @@ -228,8 +228,8 @@ void bind_demo(py::module& m) { // Classes py::class_ pyPoint(m, "Point"); - py::enum_ pyLengthUnit(pyPoint, "LengthUnit"); - py::enum_ pyAngleUnit(pyPoint, "AngleUnit"); + py::enum_ pyLengthUnit(pyPoint, "LengthUnit", "Describes the length measurement units."); + py::enum_ pyAngleUnit(pyPoint, "AngleUnit", "Describes the angle measurement units."); pyPoint .def(py::init<>()) diff --git a/test-data/unit/stubgen.test b/test-data/unit/stubgen.test index fe0538159aa36..f24eeb7c42c33 100644 --- a/test-data/unit/stubgen.test +++ b/test-data/unit/stubgen.test @@ -266,6 +266,7 @@ class A: ... [out] class A: ... + [case testSkipPrivateFunction] def _f(): ... def g(): ... @@ -3640,6 +3641,60 @@ class B: def quoteD() -> None: '''raw with quotes\\"''' +[case testIncludeDocstringsInspectMode-xfail] +# flags: --include-docstrings --inspect-mode + +# TODO: --inspect mode doesn't explicitly match the functionality when writing the function call signatures. +class A: + """class docstring + + a multiline 😊 docstring""" + def func(): + """func docstring + don't forget to indent""" + ... + def nodoc(): + ... +class B: + def quoteA(): + '''func docstring with quotes"""\\n + and an end quote\'''' + ... + def quoteB(): + '''func docstring with quotes""" + \'\'\' + and an end quote\\"''' + ... + def quoteC(): + """func docstring with end quote\\\"""" + ... + def quoteD(): + r'''raw with quotes\"''' + ... +[out] +class A: + """class docstring + + a multiline 😊 docstring""" + def func() -> None: + """func docstring + don't forget to indent""" + def nodoc() -> None: ... + +class B: + def quoteA() -> None: + '''func docstring with quotes"""\\n + and an end quote\'''' + def quoteB() -> None: + '''func docstring with quotes""" + \'\'\' + and an end quote\\"''' + def quoteC() -> None: + '''func docstring with end quote\\"''' + def quoteD() -> None: + '''raw with quotes\\"''' + + [case testIgnoreDocstrings] class A: """class docstring