Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to include docstrings with stubgen #13284

Merged
merged 33 commits into from
Aug 13, 2023
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9882092
Add option to include docstrings with stubgen
chylek Jul 29, 2022
6b85586
Add --include-docstings to stubgen docs.
chylek Jul 29, 2022
74bbee8
Fix missing newline
chylek Jul 29, 2022
485dd75
Merge remote-tracking branch 'origin/master' into issue-11965
chylek Jul 29, 2022
2979837
Fix code style
chylek Jul 29, 2022
b272ddf
Fix missing docstring argument
chylek Jul 29, 2022
eab9567
Fix code style
chylek Jul 29, 2022
e1812ef
Fix types
chylek Jul 29, 2022
eefc49e
Remove useless check
chylek Jul 29, 2022
a0e8647
Fix superfluous whitespace
chylek Jul 29, 2022
acd3168
Add more stubgen docstrings tests
chylek Aug 1, 2022
ab64284
Fix docstring option not checked
chylek Aug 1, 2022
7a36ff0
Fix indentation for docstrings
chylek Aug 1, 2022
97700e7
Add docstrings inclusion flag to fastparse
chylek Aug 1, 2022
03c285c
Add stubgenc doctring test
chylek Aug 1, 2022
0a61327
Fix coding style
chylek Aug 1, 2022
b7219a7
Merge remote-tracking branch 'origin/master' into issue-11965
chylek Aug 19, 2022
bb5b0c8
Fix type annotation
chylek Aug 19, 2022
fa1f529
Remove files added by mistake
chylek Aug 19, 2022
79d30fc
Add newline
chylek Aug 19, 2022
3fc3447
Fix issues found by shellcheck
chylek Aug 19, 2022
d232bfe
Fix formatting in stubgenc's docstring output
chylek Aug 20, 2022
3f849b9
Remove ellipsis if a function body is a docstring
chylek Aug 20, 2022
5d0cd9d
Fix invalid strings due to quotes
chylek Aug 22, 2022
7318dad
Merge remote-tracking branch 'origin/master' into issue-11965
chylek Oct 5, 2022
7ea6727
Fix incorrectly resolved merge
chylek Oct 5, 2022
4649599
Merge branch 'master' into issue-11965
chylek Oct 20, 2022
182c9e6
Merge branch 'master' into issue-11965
chylek Jan 30, 2023
6b9a46f
Merge branch 'master' into issue-11965
chylek Jan 30, 2023
a7e8209
Merge remote-tracking branch 'upstream/master' into issue-11965
chylek Aug 12, 2023
23a6a0f
Fix whitespaces
chylek Aug 12, 2023
3a7695e
Merge branch 'master' into issue-11965
hauntsaninja Aug 13, 2023
7580e22
Improve code style
chylek Aug 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/stubgen.rst
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ Additional flags
Instead, only export imported names that are not referenced in the module
that contains the import.

.. option:: --include-docstrings

Include docstrings in stubs. This will add docstrings to Python function and
classes stubs and to C extension function stubs.

.. option:: --search-path PATH

Specify module search directories, separated by colons (only used if
Expand Down
30 changes: 23 additions & 7 deletions misc/test-stubgenc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,33 @@
set -e
set -x

cd "$(dirname $0)/.."
cd "$(dirname "$0")/.."

# Install dependencies, demo project and mypy
python -m pip install -r test-requirements.txt
python -m pip install ./test-data/pybind11_mypy_demo
python -m pip install .

# Remove expected stubs and generate new inplace
STUBGEN_OUTPUT_FOLDER=./test-data/pybind11_mypy_demo/stubgen
rm -rf $STUBGEN_OUTPUT_FOLDER/*
stubgen -p pybind11_mypy_demo -o $STUBGEN_OUTPUT_FOLDER
EXIT=0

# Compare generated stubs to expected ones
git diff --exit-code $STUBGEN_OUTPUT_FOLDER
# performs the stubgenc test
# first argument is the test result folder
# everything else is passed to stubgen as its arguments
function stubgenc_test() {
chylek marked this conversation as resolved.
Show resolved Hide resolved
# Remove expected stubs and generate new inplace
STUBGEN_OUTPUT_FOLDER=./test-data/pybind11_mypy_demo/$1
rm -rf "${STUBGEN_OUTPUT_FOLDER:?}/*"
stubgen -o "$STUBGEN_OUTPUT_FOLDER" "${@:2}"

# Compare generated stubs to expected ones
if ! git diff --exit-code "$STUBGEN_OUTPUT_FOLDER";
then
EXIT=$?
fi
}

# create stubs without docstrings
stubgenc_test stubgen -p pybind11_mypy_demo
# create stubs with docstrings
stubgenc_test stubgen-include-docs -p pybind11_mypy_demo --include-docstrings
exit $EXIT
4 changes: 4 additions & 0 deletions mypy/fastparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,8 @@ def do_func_def(
# FuncDef overrides set_line -- can't use self.set_line
func_def.set_line(lineno, n.col_offset, end_line, end_column)
retval = func_def
if self.options.include_docstrings:
func_def.docstring = ast3.get_docstring(n, clean=False)
self.class_and_function_stack.pop()
return retval

Expand Down Expand Up @@ -1097,6 +1099,8 @@ def visit_ClassDef(self, n: ast3.ClassDef) -> ClassDef:
else:
cdef.line = n.lineno
cdef.deco_line = n.decorator_list[0].lineno if n.decorator_list else None
if self.options.include_docstrings:
cdef.docstring = ast3.get_docstring(n, clean=False)
cdef.column = n.col_offset
cdef.end_line = getattr(n, "end_lineno", None)
cdef.end_column = getattr(n, "end_col_offset", None)
Expand Down
4 changes: 4 additions & 0 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@ class FuncDef(FuncItem, SymbolNode, Statement):
"abstract_status",
"original_def",
"deco_line",
"docstring",
)

# Note that all __init__ args must have default values
Expand All @@ -802,6 +803,7 @@ def __init__(
self.original_def: None | FuncDef | Var | Decorator = None
# Used for error reporting (to keep backwad compatibility with pre-3.8)
self.deco_line: int | None = None
self.docstring: str | None = None

@property
def name(self) -> str:
Expand Down Expand Up @@ -1081,6 +1083,7 @@ class ClassDef(Statement):
"analyzed",
"has_incompatible_baseclass",
"deco_line",
"docstring",
)

name: str # Name of the class without module prefix
Expand Down Expand Up @@ -1122,6 +1125,7 @@ def __init__(
self.has_incompatible_baseclass = False
# Used for error reporting (to keep backwad compatibility with pre-3.8)
self.deco_line: int | None = None
self.docstring: str | None = None

def accept(self, visitor: StatementVisitor[T]) -> T:
return visitor.visit_class_def(self)
Expand Down
6 changes: 6 additions & 0 deletions mypy/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ def __init__(self) -> None:
# mypy. (Like mypyc.)
self.preserve_asts = False

# If True, function and class docstrings will be extracted and retained.
# This isn't exposed as a command line option
# because it is intended for software integrating with
# mypy. (Like stubgen.)
self.include_docstrings = False

# Paths of user plugins
self.plugins: list[str] = []

Expand Down
42 changes: 37 additions & 5 deletions mypy/stubgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ def __init__(
verbose: bool,
quiet: bool,
export_less: bool,
include_docstrings: bool,
) -> None:
# See parse_options for descriptions of the flags.
self.pyversion = pyversion
Expand All @@ -225,6 +226,7 @@ def __init__(
self.verbose = verbose
self.quiet = quiet
self.export_less = export_less
self.include_docstrings = include_docstrings


class StubSource:
Expand Down Expand Up @@ -572,6 +574,7 @@ def __init__(
include_private: bool = False,
analyzed: bool = False,
export_less: bool = False,
include_docstrings: bool = False,
) -> None:
# Best known value of __all__.
self._all_ = _all_
Expand All @@ -586,6 +589,7 @@ def __init__(
self._state = EMPTY
self._toplevel_names: list[str] = []
self._include_private = include_private
self._include_docstrings = include_docstrings
self.import_tracker = ImportTracker()
# Was the tree semantically analysed before?
self.analyzed = analyzed
Expand Down Expand Up @@ -755,7 +759,12 @@ def visit_func_def(
retfield = " -> " + retname

self.add(", ".join(args))
self.add(f"){retfield}: ...\n")
self.add(f"){retfield}:")
if self._include_docstrings and o.docstring:
self.add(f'\n{self._indent} """{o.docstring}"""\n')
else:
self.add(" ...\n")

self._state = FUNC

def is_none_expr(self, expr: Expression) -> bool:
Expand Down Expand Up @@ -927,8 +936,10 @@ def visit_class_def(self, o: ClassDef) -> None:
if base_types:
self.add(f"({', '.join(base_types)})")
self.add(":\n")
n = len(self._output)
self._indent += " "
if self._include_docstrings and o.docstring:
self.add(f'{self._indent}"""{o.docstring}"""\n')
n = len(self._output)
self._vars.append([])
super().visit_class_def(o)
self._indent = self._indent[:-4]
Expand All @@ -937,7 +948,8 @@ def visit_class_def(self, o: ClassDef) -> None:
if len(self._output) == n:
if self._state == EMPTY_CLASS and sep is not None:
self._output[sep] = ""
self._output[-1] = self._output[-1][:-1] + " ...\n"
if not (self._include_docstrings and o.docstring):
self._output[-1] = self._output[-1][:-1] + " ...\n"
self._state = EMPTY_CLASS
else:
self._state = CLASS
Expand Down Expand Up @@ -1549,6 +1561,7 @@ def mypy_options(stubgen_options: Options) -> MypyOptions:
options.python_version = stubgen_options.pyversion
options.show_traceback = True
options.transform_source = remove_misplaced_type_comments
options.include_docstrings = stubgen_options.include_docstrings
return options


Expand Down Expand Up @@ -1605,6 +1618,7 @@ def generate_stub_from_ast(
parse_only: bool = False,
include_private: bool = False,
export_less: bool = False,
include_docstrings: bool = False,
) -> None:
"""Use analysed (or just parsed) AST to generate type stub for single file.

Expand All @@ -1616,6 +1630,7 @@ def generate_stub_from_ast(
include_private=include_private,
analyzed=not parse_only,
export_less=export_less,
include_docstrings=include_docstrings,
)
assert mod.ast is not None, "This function must be used only with analyzed modules"
mod.ast.accept(gen)
Expand Down Expand Up @@ -1670,7 +1685,12 @@ def generate_stubs(options: Options) -> None:
files.append(target)
with generate_guarded(mod.module, target, options.ignore_errors, options.verbose):
generate_stub_from_ast(
mod, target, options.parse_only, options.include_private, options.export_less
mod,
target,
options.parse_only,
options.include_private,
options.export_less,
include_docstrings=options.include_docstrings,
)

# Separately analyse C modules using different logic.
Expand All @@ -1682,7 +1702,13 @@ def generate_stubs(options: Options) -> None:
target = os.path.join(options.output_dir, target)
files.append(target)
with generate_guarded(mod.module, target, options.ignore_errors, options.verbose):
generate_stub_for_c_module(mod.module, target, sigs=sigs, class_sigs=class_sigs)
generate_stub_for_c_module(
mod.module,
target,
sigs=sigs,
class_sigs=class_sigs,
include_docstrings=options.include_docstrings,
)
num_modules = len(py_modules) + len(c_modules)
if not options.quiet and num_modules > 0:
print("Processed %d modules" % num_modules)
Expand Down Expand Up @@ -1737,6 +1763,11 @@ def parse_options(args: list[str]) -> Options:
"don't implicitly export all names imported from other modules " "in the same package"
),
)
parser.add_argument(
"--include-docstrings",
action="store_true",
help=("include existing docstrings with the stubs"),
)
parser.add_argument("-v", "--verbose", action="store_true", help="show more verbose messages")
parser.add_argument("-q", "--quiet", action="store_true", help="show fewer messages")
parser.add_argument(
Expand Down Expand Up @@ -1817,6 +1848,7 @@ def parse_options(args: list[str]) -> Options:
verbose=ns.verbose,
quiet=ns.quiet,
export_less=ns.export_less,
include_docstrings=ns.include_docstrings,
)


Expand Down
59 changes: 48 additions & 11 deletions mypy/stubgenc.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def generate_stub_for_c_module(
target: str,
sigs: dict[str, str] | None = None,
class_sigs: dict[str, str] | None = None,
include_docstrings: bool = False,
) -> None:
"""Generate stub for C module.

Expand All @@ -65,15 +66,30 @@ def generate_stub_for_c_module(
items = sorted(module.__dict__.items(), key=lambda x: x[0])
for name, obj in items:
if is_c_function(obj):
generate_c_function_stub(module, name, obj, functions, imports=imports, sigs=sigs)
generate_c_function_stub(
module,
name,
obj,
functions,
imports=imports,
sigs=sigs,
include_docstrings=include_docstrings,
)
done.add(name)
types: list[str] = []
for name, obj in items:
if name.startswith("__") and name.endswith("__"):
continue
if is_c_type(obj):
generate_c_type_stub(
module, name, obj, types, imports=imports, sigs=sigs, class_sigs=class_sigs
module,
name,
obj,
types,
imports=imports,
sigs=sigs,
class_sigs=class_sigs,
include_docstrings=include_docstrings,
)
done.add(name)
variables = []
Expand Down Expand Up @@ -157,10 +173,11 @@ def generate_c_function_stub(
sigs: dict[str, str] | None = None,
class_name: str | None = None,
class_sigs: dict[str, str] | None = None,
include_docstrings: bool = False,
) -> None:
"""Generate stub for a single function or method.

The result (always a single line) will be appended to 'output'.
The result will be appended to 'output'.
If necessary, any required names will be added to 'imports'.
The 'class_name' is used to find signature of __init__ or __new__ in
'class_sigs'.
Expand All @@ -171,7 +188,7 @@ def generate_c_function_stub(
class_sigs = {}

ret_type = "None" if name == "__init__" and class_name else "Any"

docstr = None
if (
name in ("__new__", "__init__")
and name not in sigs
Expand Down Expand Up @@ -237,13 +254,24 @@ def generate_c_function_stub(

if is_overloaded:
output.append("@overload")
output.append(
"def {function}({args}) -> {ret}: ...".format(
function=name,
args=", ".join(sig),
ret=strip_or_import(signature.ret_type, module, imports),
if include_docstrings and docstr:
output.append(
"def {function}({args}) -> {ret}:".format(
function=name,
args=", ".join(sig),
ret=strip_or_import(signature.ret_type, module, imports),
)
)
docstr_indented = "\n ".join(docstr.strip().split("\n"))
output.extend(f' """{docstr_indented}"""'.split("\n"))
chylek marked this conversation as resolved.
Show resolved Hide resolved
else:
output.append(
"def {function}({args}) -> {ret}: ...".format(
function=name,
args=", ".join(sig),
ret=strip_or_import(signature.ret_type, module, imports),
)
Copy link
Member

@ilevkivskyi ilevkivskyi Aug 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this part completely repeats the other branch? If yes, then please use

if foo:
   bar
baz

instead of

if foo:
    bar
    baz
else:
    baz

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be more precise, in this case you need:

bar
if foo:
    baz

since you always need a type, and docstring should come after (only if the flag is enabled)

)
)


def strip_or_import(typ: str, module: ModuleType, imports: list[str]) -> str:
Expand Down Expand Up @@ -340,6 +368,7 @@ def generate_c_type_stub(
imports: list[str],
sigs: dict[str, str] | None = None,
class_sigs: dict[str, str] | None = None,
include_docstrings: bool = False,
) -> None:
"""Generate stub for a single class using runtime introspection.

Expand Down Expand Up @@ -383,6 +412,7 @@ def generate_c_type_stub(
sigs=sigs,
class_name=class_name,
class_sigs=class_sigs,
include_docstrings=include_docstrings,
)
elif is_c_property(value):
done.add(attr)
Expand All @@ -398,7 +428,14 @@ def generate_c_type_stub(
)
elif is_c_type(value):
generate_c_type_stub(
module, attr, value, types, imports=imports, sigs=sigs, class_sigs=class_sigs
module,
attr,
value,
types,
imports=imports,
sigs=sigs,
class_sigs=class_sigs,
include_docstrings=include_docstrings,
)
done.add(attr)

Expand Down
2 changes: 1 addition & 1 deletion test-data/pybind11_mypy_demo/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ void bind_basics(py::module& basics) {
using namespace basics;

// Functions
basics.def("answer", &answer);
basics.def("answer", &answer, "answer docstring"); // tests explicit docstrings
basics.def("sum", &sum);
basics.def("midpoint", &midpoint, py::arg("left"), py::arg("right"));
basics.def("weighted_midpoint", weighted_midpoint, py::arg("left"), py::arg("right"), py::arg("alpha")=0.5);
Expand Down
Loading