Skip to content
50 changes: 45 additions & 5 deletions pybind11_stubgen/parser/mixins/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class ParserDispatchMixin(IParser):
def handle_class(self, path: QualifiedName, class_: type) -> Class | None:
base_classes = class_.__bases__
result = Class(name=path[-1], bases=self.handle_bases(path, base_classes))
for name, member in inspect.getmembers(class_):
for name, member in self._iter_class_members(class_):
obj = self.handle_class_member(
QualifiedName([*path, Identifier(name)]), class_, member
)
Expand All @@ -70,6 +70,30 @@ def handle_class(self, path: QualifiedName, class_: type) -> Class | None:
raise AssertionError()
return result

def _iter_class_members(self, class_: type):
seen: set[str] = set()

# Iterate __dict__ keys for definition order, but resolve values
# through getattr() so descriptors (staticmethod, properties, etc.)
# are properly unwrapped — matching inspect.getmembers() semantics.
for name in class_.__dict__:
seen.add(name)
try:
value = getattr(class_, name)
except AttributeError:
continue
yield name, value

# Append inherited or lazily exposed members from dir().
for name in dir(class_):
if name in seen:
continue
try:
value = getattr(class_, name)
except AttributeError:
continue
yield name, value

def handle_class_member(
self, path: QualifiedName, class_: type, member: Any
) -> Docstring | Alias | Class | list[Method] | Field | Property | None:
Expand All @@ -89,7 +113,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 self._iter_module_members(module):
obj = self.handle_module_member(
QualifiedName([*path, Identifier(name)]), module, member
)
Expand Down Expand Up @@ -117,6 +141,24 @@ def handle_module(

return result

def _iter_module_members(self, module: types.ModuleType):
seen: set[str] = set()

# Preserve definition order for regular module globals, which reflects
# pybind11 registration order, and then append lazily exposed members.
for name, member in module.__dict__.items():
seen.add(name)
yield name, member

for name in dir(module):
if name in seen:
continue
try:
member = getattr(module, name)
except AttributeError:
continue
yield name, member

def handle_module_member(
self, path: QualifiedName, module: types.ModuleType, member: Any
) -> (
Expand Down Expand Up @@ -647,9 +689,7 @@ def parse_function_docstring(
# This syntax is not supported before Python 3.12.
return []
type_vars: list[str] = list(
filter(
bool, map(str.strip, (type_vars_group or "").split(","))
)
filter(bool, map(str.strip, (type_vars_group or "").split(",")))
)
args = self.call_with_local_types(
type_vars, lambda: self.parse_args_str(match.group("args"))
Expand Down
114 changes: 112 additions & 2 deletions pybind11_stubgen/printer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import dataclasses
import logging
import sys

from pybind11_stubgen.structs import (
Expand All @@ -24,15 +25,124 @@
Value,
)

log = logging.getLogger("pybind11_stubgen")


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


def _referenced_local_dependency_name(expr: str) -> str | None:
"""Extract the local dependency name from a dotted identifier expression.

Returns the first component if *expr* is a valid dotted identifier
(e.g. ``"ParIter"`` -> ``"ParIter"``, ``"Outer.Inner"`` -> ``"Outer"``),
or ``None`` for anything else (literals, calls, etc.).
"""
parts = expr.split(".")
if not parts or any(not part.isidentifier() for part in parts):
return None
return parts[0]


def _topological_sort_classes(classes: list[Class]) -> list[Class]:
"""Sort classes so that dependencies appear before dependents.

Considers two kinds of edges:
- Inheritance: base classes must appear before derived classes.
- Runtime references: class-body aliases (``Foo = Bar``) and field
values that name a sibling class are executable at import time,
so the referenced class must already be defined.
(``from __future__ import annotations`` only defers *type annotations*,
not attribute/alias assignments.)

Uses Kahn's algorithm. Ties are broken by input position for stability.
Only references whose first identifier component names a sibling class in
the current scope contribute edges. External references are ignored.
"""
if not classes:
return classes

name_to_index = {c.name: i for i, c in enumerate(classes)}
name_to_class = {c.name: c for c in classes}

# Build adjacency list: dependency -> [dependent, ...]
# and in-degree count for each class
children: dict[str, list[str]] = {c.name: [] for c in classes}
in_degree: dict[str, int] = {c.name: 0 for c in classes}
seen_edges: set[tuple[str, str]] = set()

def _add_edge(dependency: str, dependent: str) -> None:
edge = (dependency, dependent)
if edge in seen_edges:
return
seen_edges.add(edge)
children[dependency].append(dependent)
in_degree[dependent] += 1

for c in classes:
# Inheritance edges: base -> derived
for base in c.bases:
base_name = str(base[0])
if base_name in name_to_class:
_add_edge(base_name, c.name)

# Alias edges: ``Iterator = ParIter`` is a runtime assignment
for alias in c.aliases:
origin_name = str(alias.origin[0])
if origin_name in name_to_class and origin_name != c.name:
_add_edge(origin_name, c.name)

# Field-value edges: a print-safe field like ``Iterator = ParIter``
# (parsed as a Field rather than an Alias in some configurations)
for field in c.fields:
val = field.attribute.value
if val is not None and val.is_print_safe:
val_name = _referenced_local_dependency_name(val.repr)
if (
val_name is not None
and val_name in name_to_class
and val_name != c.name
):
_add_edge(val_name, c.name)

# Initialize queue with zero in-degree classes, sorted by input position
queue = sorted(
[name for name, deg in in_degree.items() if deg == 0],
key=lambda n: name_to_index[n],
)

result = []
while queue:
name = queue.pop(0)
result.append(name_to_class[name])
# Sort children by input position for stable ordering
for child in sorted(children[name], key=lambda n: name_to_index[n]):
in_degree[child] -= 1
if in_degree[child] == 0:
queue.append(child)
# Re-sort queue to maintain input-position priority
queue.sort(key=lambda n: name_to_index[n])

if len(result) < len(classes):
remaining = [c for c in classes if c.name not in {r.name for r in result}]
log.warning(
"Cycle detected in class dependencies involving: %s. "
"Appending in original order.",
[c.name for c in remaining],
)
result.extend(remaining)

return result


class Printer:
def __init__(self, invalid_expr_as_ellipses: bool):
self.invalid_expr_as_ellipses = invalid_expr_as_ellipses

def _order_classes(self, classes: list[Class]) -> list[Class]:
return _topological_sort_classes(classes)

def print_alias(self, alias: Alias) -> list[str]:
return [f"{alias.name} = {alias.origin}"]

Expand Down Expand Up @@ -90,7 +200,7 @@ 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):
for sub_class in self._order_classes(class_.classes):
result.extend(self.print_class(sub_class))

modifier_order: dict[Modifier, int] = {
Expand Down Expand Up @@ -232,7 +342,7 @@ 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):
for class_ in self._order_classes(module.classes):
result.extend(self.print_class(class_))

for func in sorted(module.functions, key=lambda f: f.name):
Expand Down
47 changes: 39 additions & 8 deletions tests/demo-lib/include/demo/Inheritance.h
Original file line number Diff line number Diff line change
@@ -1,15 +1,46 @@
#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 MyBase {
struct Inner{};
std::string name;
};

struct Derived : Base {
int count;
};
struct Derived : MyBase {
int count;
};

// Cross-reference test (the "cyclic" case from issue #231 / PR #275):
// ParIterBase is a base class for ParIter.
// ParticleContainer references ParIter (via an alias).
// ParIter.__init__ takes a ParticleContainer& (annotation back-reference).
// This is NOT cyclic inheritance — just interleaved name usage.

struct ParIterBase {
int level;
};

struct ParticleContainer; // forward declaration

struct ParIter : ParIterBase {
ParticleContainer* container;
ParIter(ParticleContainer& pc, int level);
};

struct ParticleContainer {
std::string name;
void process(ParIter& it);
};

inline ParIter::ParIter(ParticleContainer& pc, int level)
: container(&pc), ParIterBase{level} {}

inline void ParticleContainer::process(ParIter& it) {
it.level += 1;
}
}
33 changes: 29 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 All @@ -38,6 +38,31 @@ void bind_classes_module(py::module&&m) {
.def("g", &demo::Foo::Child::g);
}

// Cross-reference / "cyclic" test case (issue #231, PR #275):
// Binding registration order here is ParIterBase, then ParticleContainer,
// then ParIter.
// ParticleContainer.Iterator is an alias to ParIter (cross-ref).
// ParIter inherits ParIterBase and takes ParticleContainer in __init__.
// The topological sort must put ParIterBase before ParIter;
// from __future__ import annotations handles the annotation back-refs.
{
auto pyParIterBase = py::class_<demo::ParIterBase>(m, "ParIterBase");
pyParIterBase.def_readwrite("level", &demo::ParIterBase::level);

auto pyParticleContainer = py::class_<demo::ParticleContainer>(m, "ParticleContainer");
pyParticleContainer.def_readwrite("name", &demo::ParticleContainer::name);

auto pyParIter = py::class_<demo::ParIter, demo::ParIterBase>(m, "ParIter");
pyParIter.def(py::init<demo::ParticleContainer&, int>(),
py::arg("particle_container"), py::arg("level"));

// Bind after ParIter is registered so pybind11 resolves the Python type
pyParticleContainer.def("process", &demo::ParticleContainer::process);

// Alias: ParticleContainer.Iterator = ParIter
pyParticleContainer.attr("Iterator") = pyParIter;
}

{
py::register_exception<demo::CppException>(m, "CppException");
}
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
Loading
Loading