Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 13 additions & 1 deletion pybind11_stubgen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class CLIArgs(Namespace):
exit_code: bool
dry_run: bool
stub_extension: str
sort_by: str
module_name: str


Expand Down Expand Up @@ -216,6 +217,14 @@ def regex_colon_path(regex_path: str) -> tuple[re.Pattern, str]:
"Must be 'pyi' (default) or 'py'",
)

parser.add_argument(
"--sort-by",
type=str,
default="definition",
choices=["definition", "topological"],
help="Sort classes by 'definition' order (default) or 'topological' order.",
)

parser.add_argument(
"module_name",
metavar="MODULE_NAME",
Expand Down Expand Up @@ -309,7 +318,10 @@ def main(argv: Sequence[str] | None = None) -> None:
args = arg_parser().parse_args(argv, namespace=CLIArgs())

parser = stub_parser_from_args(args)
printer = Printer(invalid_expr_as_ellipses=not args.print_invalid_expressions_as_is)
printer = Printer(
invalid_expr_as_ellipses=not args.print_invalid_expressions_as_is,
sort_by=args.sort_by,
)

out_dir, sub_dir = to_output_and_subdir(
output_dir=args.output_dir,
Expand Down
2 changes: 1 addition & 1 deletion pybind11_stubgen/parser/mixins/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def handle_module(
self, path: QualifiedName, module: types.ModuleType
) -> Module | None:
result = Module(name=path[-1])
for name, member in inspect.getmembers(module):
for name, member in module.__dict__.items():
obj = self.handle_module_member(
QualifiedName([*path, Identifier(name)]), module, member
)
Expand Down
54 changes: 51 additions & 3 deletions pybind11_stubgen/printer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

import dataclasses
import logging
import sys
from collections import defaultdict

from pybind11_stubgen.structs import (
Alias,
Expand All @@ -24,14 +26,52 @@
Value,
)

log = logging.getLogger("pybind11_stubgen")


def indent_lines(lines: list[str], by=4) -> list[str]:
return [" " * by + line for line in lines]


class Printer:
def __init__(self, invalid_expr_as_ellipses: bool):
def __init__(self, invalid_expr_as_ellipses: bool, sort_by: str = "definition"):
self.invalid_expr_as_ellipses = invalid_expr_as_ellipses
self.sort_by = sort_by

def _toposort_classes(self, classes: list[Class]) -> list[Class]:
in_degree = {c.name: 0 for c in classes}
graph = defaultdict(list)
class_map = {c.name: c for c in classes}

for c in classes:
for base in c.bases:
base_name = base[-1]
if base_name in class_map:
graph[base_name].append(c.name)
in_degree[c.name] += 1

queue = sorted([name for name, degree in in_degree.items() if degree == 0])

sorted_classes = []
while queue:
name = queue.pop(0)
sorted_classes.append(class_map[name])
for neighbor in sorted(graph[name]):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)

if len(sorted_classes) == len(classes):
return sorted_classes
else:
# Cycle detected, fallback to alphabetical sort
remaining = [c for c in classes if c not in sorted_classes]
Copy link
Copy Markdown
Contributor

@skarndev skarndev Jan 29, 2026

Choose a reason for hiding this comment

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

Are such cycles actually possible with pybind11? In my understanding of the Python type system, it enforces DAG for inheritance, which makes such cases ill-formed.
It should either raise a NameError at runtime, or a TypeError (Cannot create a consistent method resolution order (MRO)). Does pybind11 do some magic here to make this possible?

If not, maybe we should make it a hard error.
@ax3l I recall you specifically mentioned this issue as the most severe one. Was it in regards just to the definition order sorting or cyclic inheritance included?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The server issue I mentioned is the test included so far in this PR: inheritance should not be alphabetically sorted, because it breaks definition order.

While testing this PR, I noticed that "cyclic" uses like these are now broken. I can simplify that example a bit more, but the essence here is not a cyclic inheritance but a usage in a method interleaving definitions...

Copy link
Copy Markdown
Contributor

@skarndev skarndev Jan 29, 2026

Choose a reason for hiding this comment

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

Ah, that is a different issue from the definition order toposort. We can handle this in a separate PR, that would be easier. Your example does not show an inheritance cycle, but a name availability issue. When the class is not yet defined its name is not available prior to python 3.14.

There are ways to tackle that pretty easily. The universal way for all versions of Python is to just enclose the not-yet-defined name into a string literal "". This acts as a forward declaration.

There are some things to consider though.
The naive solution is to track if a method is referencing "self", the name of the class this method belongs to.

However, ideally we should track the already defined names, and if any name in the annotation context appears before it is defined, we enclose it in a forward declaration literal. That would also handle cases of unresolved names in a way that the unresolved name is still printed, but does not break Python code from evaluation. That covers the incorrect "C++ types" sneaking in which might have :: in them (that's pybind11's behavior on generating such doctrings). That would also make sure tools like black are able to succesfully format such a file, and stubgen exits normally.

Copy link
Copy Markdown
Contributor

@skarndev skarndev Jan 29, 2026

Choose a reason for hiding this comment

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

That would also automatically free us from the implementing this toposort logic for anything else than class bases, I believe.
Even with this PR the following should result into an error:

class Foo {};

void bar(Foo& foo) {}

PYBIND11_MODULE(baz, m) {
     m.def("bar", &bar);
     pybind11::class_<Foo> _cls(m, "Foo");
}

That, I believe, would produce:

def bar(Foo foo) -> None: ...

class Foo: ...

What we will get with stringification (which is totally okay with mypy and other type checkers):

def bar("Foo" foo) -> None: ...

class Foo: ...

Aside from that, there is one last issue that may exist with dependency cycles is cyclic imports between modules. But I think this is not an issue for .pyi stubs, judging from how mypy treats if TYPE_CHECKING: blocks in normal .py.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@juelg can you please allow the maintainers to push into the branch? I plan to bring this close to merge this weekend.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks @skarndev! The PR should already give maintainers push access to the branch.

log.warning(
"Cycle detected in class inheritance involving: %s. "
"Falling back to alphabetical sort for these classes.",
[c.name for c in remaining],
)
return sorted_classes + sorted(remaining, key=lambda c: c.name)

def print_alias(self, alias: Alias) -> list[str]:
return [f"{alias.name} = {alias.origin}"]
Expand Down Expand Up @@ -90,7 +130,11 @@ def print_class_body(self, class_: Class) -> list[str]:
if class_.doc is not None:
result.extend(self.print_docstring(class_.doc))

for sub_class in sorted(class_.classes, key=lambda c: c.name):
classes_to_print = class_.classes
if self.sort_by == "topological":
classes_to_print = self._toposort_classes(class_.classes)

for sub_class in classes_to_print:
result.extend(self.print_class(sub_class))

modifier_order: dict[Modifier, int] = {
Expand Down Expand Up @@ -225,7 +269,11 @@ def print_module(self, module: Module) -> list[str]:
for type_var in sorted(module.type_vars, key=lambda t: t.name):
result.extend(self.print_type_var(type_var))

for class_ in sorted(module.classes, key=lambda c: c.name):
classes_to_print = module.classes
if self.sort_by == "topological":
classes_to_print = self._toposort_classes(module.classes)

for class_ in classes_to_print:
result.extend(self.print_class(class_))

for func in sorted(module.functions, key=lambda f: f.name):
Expand Down
20 changes: 11 additions & 9 deletions tests/demo-lib/include/demo/Inheritance.h
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
#pragma once
#include <string>

namespace demo{
namespace demo
{
// note: class stubs must not be sorted
// https://github.com/sizmailov/pybind11-stubgen/issues/231

struct Base {
struct Inner{};
std::string name;
};

struct Derived : Base {
int count;
};
struct MyBase {
struct Inner{};
std::string name;
};

struct Derived : MyBase {
int count;
};
}
14 changes: 7 additions & 7 deletions tests/demo.errors.stderr.txt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
pybind11_stubgen - [ ERROR] In demo._bindings.aliases.foreign_enum_default : Invalid expression '<ConsoleForegroundColor.Blue: 34>'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.dense_matrix_c : Can't find/import 'm'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.dense_matrix_c : Can't find/import 'n'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.dense_matrix_r : Can't find/import 'm'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.dense_matrix_r : Can't find/import 'n'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.four_col_matrix_r : Can't find/import 'm'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.dense_matrix_c : Can't find/import 'm'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.dense_matrix_c : Can't find/import 'n'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.four_row_matrix_r : Can't find/import 'n'
pybind11_stubgen - [ ERROR] In demo._bindings.eigen.four_col_matrix_r : Can't find/import 'm'
pybind11_stubgen - [ ERROR] In demo._bindings.enum.accept_defaulted_enum : Invalid expression '<ConsoleForegroundColor.None_: -1>'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.accept_unbound_enum : Invalid expression '(anonymous namespace)::Enum'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.accept_unbound_enum_defaulted : Invalid expression '<demo._bindings.flawed_bindings.Enum object at 0x1234abcd5678>'
pybind11_stubgen - [ ERROR] In demo._bindings.aliases.foreign_enum_default : Invalid expression '<ConsoleForegroundColor.Blue: 34>'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.get_unbound_type : Invalid expression '(anonymous namespace)::Unbound'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.accept_unbound_type : Invalid expression '(anonymous namespace)::Unbound'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.accept_unbound_enum : Invalid expression '(anonymous namespace)::Enum'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.accept_unbound_type_defaulted : Invalid expression '<demo._bindings.flawed_bindings.Unbound object at 0x1234abcd5678>'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.get_unbound_type : Invalid expression '(anonymous namespace)::Unbound'
pybind11_stubgen - [ ERROR] In demo._bindings.flawed_bindings.accept_unbound_enum_defaulted : Invalid expression '<demo._bindings.flawed_bindings.Enum object at 0x1234abcd5678>'
pybind11_stubgen - [WARNING] Enum-like str representations were found with no matching mapping to the enum class location.
Use `--enum-class-locations` to specify full path to the following enum(s):
- ConsoleForegroundColor
Expand Down
8 changes: 4 additions & 4 deletions tests/py-demo/bindings/src/modules/classes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ void bind_classes_module(py::module&&m) {
}

{
py::class_<demo::Base> pyBase(m, "Base");
py::class_<demo::MyBase> pyMyBase(m, "MyBase");

pyBase.def_readwrite("name", &demo::Base::name);
pyMyBase.def_readwrite("name", &demo::MyBase::name);

py::class_<demo::Base::Inner>(pyBase, "Inner");
py::class_<demo::MyBase::Inner>(pyMyBase, "Inner");

py::class_<demo::Derived, demo::Base>(m, "Derived")
py::class_<demo::Derived, demo::MyBase>(m, "Derived")
.def_readwrite("count", &demo::Derived::count);

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ __all__: list[str] = [
"random",
]

class Color:
pass

class Dummy:
linalg = numpy.linalg

class Color:
pass

def foreign_enum_default(
color: typing.Any = demo._bindings.enum.ConsoleForegroundColor.Blue,
) -> None: ...
def func(arg0: int) -> int: ...

local_func_alias = func
local_type_alias = Color
local_func_alias = func
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,22 @@ class Outer:
value: Outer.Inner.NestedEnum

inner: Outer.Inner

class MyBase:
class Inner:
pass
name: str

class Derived(MyBase):
count: int

class Foo:
class FooChild:
def __init__(self) -> None: ...
def g(self) -> None: ...

def __init__(self) -> None: ...
def f(self) -> None: ...

class CppException(Exception):
pass
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ __all__: list[str] = [
"get_unbound_type",
]

class Enum:
class Unbound:
pass

class Unbound:
class Enum:
pass

def accept_unbound_enum(arg0: ...) -> int: ...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import typing
__all__: list[str] = [
"Foo",
"accept_callable",
"accept_frozenset",
"accept_py_handle",
"accept_py_object",
"accept_set",
Expand All @@ -28,7 +27,6 @@ class Foo:
def __init__(self, arg0: int) -> None: ...

def accept_callable(arg0: typing.Callable) -> typing.Any: ...
def accept_frozenset(arg0: frozenset) -> None: ...
def accept_py_handle(arg0: typing.Any) -> str: ...
def accept_py_object(arg0: typing.Any) -> str: ...
def accept_set(arg0: set) -> None: ...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,52 @@ __all__: list[str] = [
"WithoutDoc",
]

class WithoutDoc:
"""
No user docstring provided
"""

def_property_readonly_static: typing.ClassVar[int] = 0
def_property_static: typing.ClassVar[int] = 0
def_property: int
def_readwrite: int
@property
def def_property_readonly(self) -> int: ...
@property
def def_readonly(self) -> int: ...

class WithPropDoc:
"""
User docstring provided only to `def_` calls
"""

def_property_readonly_static: typing.ClassVar[int] = 0
def_property_static: typing.ClassVar[int] = 0
@property
def def_property(self) -> int:
"""
prop doc token
"""
@def_property.setter
def def_property(self, arg1: int) -> None: ...
@property
def def_property_readonly(self) -> int:
"""
prop doc token
"""
@property
def def_readonly(self) -> int:
"""
prop doc token
"""
@property
def def_readwrite(self) -> int:
"""
prop doc token
"""
@def_readwrite.setter
def def_readwrite(self, arg0: int) -> None: ...

class WithGetterSetterDoc:
"""
User docstring provided via pybind11::cpp_function(..., doc) to getters/setters, but NOT to `def_*(..., doc)` calls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ from __future__ import annotations

import typing

import pybind11_stubgen.typing_ext

__all__: list[str] = [
"std_array",
"std_map",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,5 +137,28 @@ class VectorPairStringDouble:
Remove the first item from the list whose value is x. It is an error if there is no such item.
"""

class MapStringComplex:
def __bool__(self) -> bool:
"""
Check whether the map is nonempty
"""
@typing.overload
def __contains__(self, arg0: str) -> bool: ...
@typing.overload
def __contains__(self, arg0: typing.Any) -> bool: ...
def __delitem__(self, arg0: str) -> None: ...
def __getitem__(self, arg0: str) -> complex: ...
def __init__(self) -> None: ...
def __iter__(self) -> typing.Iterator: ...
def __len__(self) -> int: ...
def __repr__(self) -> str:
"""
Return the canonical string representation of this map.
"""
def __setitem__(self, arg0: str, arg1: complex) -> None: ...
def items(self) -> typing.ItemsView[MapStringComplex]: ...
def keys(self) -> typing.KeysView[MapStringComplex]: ...
def values(self) -> typing.ValuesView[MapStringComplex]: ...

def get_complex_map() -> MapStringComplex: ...
def get_vector_of_pairs() -> VectorPairStringDouble: ...
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ __all__: list[str] = [
"random",
]

class Color:
pass

class Dummy:
linalg = numpy.linalg

class Color:
pass

def foreign_enum_default(
color: typing.Any = demo._bindings.enum.ConsoleForegroundColor.Blue,
) -> None: ...
def func(arg0: int) -> int: ...

local_func_alias = func
local_type_alias = Color
local_func_alias = func
Loading
Loading