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
11 changes: 11 additions & 0 deletions pybind11_stubgen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class CLIArgs(Namespace):
print_invalid_expressions_as_is: bool
print_safe_value_reprs: re.Pattern | None
print_value_comments: bool
print_overload_fallback: bool
exit_code: bool
dry_run: bool
stub_extension: str
Expand Down Expand Up @@ -202,6 +203,15 @@ def regex_colon_path(regex_path: str) -> tuple[re.Pattern, str]:
"i.e., '... # value = <value>'",
)

parser.add_argument(
"--print-overload-fallback",
default=False,
action="store_true",
dest="print_overload_fallback",
help="Emit a concrete fallback definition after overload sets. "
"This allows re-exports to resolve the symbol via tools like Griffe.",
)

parser.add_argument(
"--exit-code",
action="store_true",
Expand Down Expand Up @@ -325,6 +335,7 @@ def main(argv: Sequence[str] | None = None) -> None:
printer = Printer(
invalid_expr_as_ellipses=not args.print_invalid_expressions_as_is,
print_value_comments=args.print_value_comments,
print_overload_fallback=args.print_overload_fallback,
)

run(
Expand Down
118 changes: 115 additions & 3 deletions pybind11_stubgen/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Modifier,
Module,
Property,
QualifiedName,
ResolvedType,
TypeVar_,
Value,
Expand Down Expand Up @@ -141,13 +142,120 @@ def __init__(
self,
invalid_expr_as_ellipses: bool,
print_value_comments: bool = False,
print_overload_fallback: bool = False,
):
self.invalid_expr_as_ellipses = invalid_expr_as_ellipses
self.print_value_comments = print_value_comments
self.print_overload_fallback = print_overload_fallback

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

def _is_overload(self, func: Function) -> bool:
"""Check if a function has the @overload decorator."""
return any("overload" in str(decorator) for decorator in func.decorators)

def _create_fallback_function(self, func: Function) -> Function:
"""Create a fallback function with broad parameter types.

Returns a new Function with:
- Same name
- All parameters accept Any type
- Return type is Any
- No @overload decorator
- No docstring
"""
fallback_args = []
for arg in func.args:
# Keep the argument structure but replace annotation with Any
new_arg = Argument(
name=arg.name,
pos_only=arg.pos_only,
kw_only=arg.kw_only,
variadic=arg.variadic,
kw_variadic=arg.kw_variadic,
default=arg.default,
annotation=ResolvedType(QualifiedName.from_str("typing.Any")),
)
fallback_args.append(new_arg)

# Return type is Any
fallback_return = ResolvedType(QualifiedName.from_str("typing.Any"))

fallback_func = Function(
name=func.name,
args=fallback_args,
returns=fallback_return,
doc=None,
decorators=[], # No @overload decorator
type_vars=func.type_vars,
)
return fallback_func

def _process_with_fallbacks(
self,
items: list,
get_key,
get_function,
create_wrapper,
) -> list:
"""Generic method to process items and insert fallback definitions after overload groups.

Args:
items: List of items to process (Functions or Methods)
get_key: Callable to extract the grouping key from an item
get_function: Callable to extract the Function from an item
create_wrapper: Callable to wrap a Function back into the original type
(receives fallback_func and first original item)
"""
if not self.print_overload_fallback or not items:
return items

# Group items by key while preserving order
groups: dict = {}
group_order: list = []

for item in items:
key = get_key(item)
if key not in groups:
groups[key] = []
group_order.append(key)
groups[key].append(item)

result = []
for key in group_order:
group = groups[key]
result.extend(group)

# Check if any item in the group has an overload
if any(self._is_overload(get_function(item)) for item in group):
fallback_func = self._create_fallback_function(get_function(group[0]))
result.append(create_wrapper(fallback_func, group[0]))

return result

def _process_functions_with_fallbacks(
self, functions: list[Function]
) -> list[Function]:
"""Process functions and insert fallback definitions after overload groups."""
return self._process_with_fallbacks(
functions,
get_key=lambda f: f.name,
get_function=lambda f: f,
create_wrapper=lambda f, _: f,
)

def _process_methods_with_fallbacks(self, methods: list[Method]) -> list[Method]:
"""Process methods and insert fallback definitions after overload groups."""
return self._process_with_fallbacks(
methods,
get_key=lambda m: (m.modifier, m.function.name),
get_function=lambda m: m.function,
create_wrapper=lambda f, orig_method: Method(
function=f, modifier=orig_method.modifier
),
)

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

Expand Down Expand Up @@ -224,9 +332,11 @@ def print_class_body(self, class_: Class) -> list[str]:
for alias in sorted(class_.aliases, key=lambda a: a.name):
result.extend(self.print_alias(alias))

for method in sorted(
sorted_methods = sorted(
class_.methods, key=lambda m: (modifier_order[m.modifier], m.function.name)
):
)
processed_methods = self._process_methods_with_fallbacks(sorted_methods)
for method in processed_methods:
result.extend(self.print_method(method))

for prop in sorted(class_.properties, key=lambda p: p.name):
Expand Down Expand Up @@ -353,7 +463,9 @@ def print_module(self, module: Module) -> list[str]:
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):
sorted_functions = sorted(module.functions, key=lambda f: f.name)
processed_functions = self._process_functions_with_fallbacks(sorted_functions)
for func in processed_functions:
result.extend(self.print_function(func))

for attr in sorted(module.attributes, key=lambda a: a.name):
Expand Down
61 changes: 61 additions & 0 deletions tests/test_overload_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Tests for overload fallback stub emission."""

from pybind11_stubgen.structs import (
Argument,
Decorator,
Function,
Identifier,
QualifiedName,
ResolvedType,
Module,
)
from pybind11_stubgen.printer import Printer


def _make_overload_function(type_name: str) -> Function:
return Function(
name=Identifier("func"),
args=[
Argument(
name=Identifier("x"),
annotation=ResolvedType(QualifiedName.from_str(type_name)),
)
],
returns=ResolvedType(QualifiedName.from_str(type_name)),
decorators=[Decorator("typing.overload")],
)


def _render_module(*, print_overload_fallback: bool) -> str:
printer = Printer(
invalid_expr_as_ellipses=True,
print_value_comments=False,
print_overload_fallback=print_overload_fallback,
)
module = Module(
name=Identifier("test_module"),
functions=[
_make_overload_function("int"),
_make_overload_function("str"),
],
)
return "\n".join(printer.print_module(module))


def test_overload_fallback_enabled() -> None:
output_str = _render_module(print_overload_fallback=True)

assert "@typing.overload" in output_str
assert output_str.count("def func") == 3
assert "typing.Any" in output_str

lines = output_str.split("\n")
last_def_idx = max(i for i, line in enumerate(lines) if "def func" in line)
if last_def_idx > 0:
assert "@overload" not in lines[last_def_idx - 1].strip()


def test_overload_fallback_disabled() -> None:
output_str = _render_module(print_overload_fallback=False)

assert output_str.count("def func") == 2
Loading