Skip to content

Commit ae80391

Browse files
authored
Fix: current-module prefix stripping (#301)
`fix.py`: Current-module prefix stripping now also walks nested annotation parameters instead of only the outer type. The fix covers the exact shapes that broke in my downstream projects (pyAMReX, ImpactX): `list[...]`, `typing.Optional[...]`, and `dict[..., ...]` for the type `...`. Concrete example seen downstream in ImpactX for `impactx.MADXParser.*` types: LHS broken -> RHS correct now. - `list[impactx.MADXParser.Token]` -> `list[Token]` - `typing.Optional[impactx.MADXParser.Expression]` -> `typing.Optional[Expression]` - `dict[str, impactx.MADXParser.Expression]` -> `dict[str, Expression]` This originated in a pure Python file that is used on top of pybind11 modules. I have seen similar issues for a while though and [patch around](https://github.com/AMReX-Codes/pyamrex/blob/26.04/.github/update_stub.sh#L20-L26) them [awkwardly](https://github.com/BLAST-ImpactX/impactx/blob/26.03/.github/update_stub.sh#L19-L21). `parse.py`: Runtime generic annotations are parsed before falling back to opaque values, and `_is_generic_alias()` now also recognizes typing generics like `Optional[...]` <details> <summary>Reproducer (PyTest)</summary> ```py """Regression tests for current-module prefix cleanup in parsed annotations.""" from argparse import Namespace from types import ModuleType from pybind11_stubgen import stub_parser_from_args from pybind11_stubgen.printer import Printer from pybind11_stubgen.structs import QualifiedName def make_args() -> Namespace: """Build the same parser configuration shape used by the CLI entrypoint.""" return Namespace( output_dir=".", root_suffix=None, ignore_invalid_expressions=None, ignore_invalid_identifiers=None, ignore_unresolved_names=None, ignore_all_errors=False, enum_class_locations=[], numpy_array_wrap_with_annotated=False, numpy_array_use_type_var=False, numpy_array_remove_parameters=False, print_invalid_expressions_as_is=False, print_safe_value_reprs=None, print_value_comments=False, exit_code=False, dry_run=True, stub_extension="pyi", module_names=[], ) def make_module() -> ModuleType: """ Create an in-memory module that mirrors the ImpactX regression. The module intentionally does not use ``from __future__ import annotations``. That forces Python to evaluate local annotations such as ``list[Token]`` into runtime generic objects whose string form contains the fully-qualified module name, e.g. ``list[impactx.MADXParser.Token]``. """ module = ModuleType("impactx.MADXParser") module.__dict__["__name__"] = "impactx.MADXParser" exec( """ from typing import Optional class Token: pass class Expression: pass def tokenize(tokens: list[Token], expr: Optional[Expression] = None) -> dict[str, Expression]: pass """, module.__dict__, ) return module def test_current_module_prefix_is_stripped_from_runtime_generic_annotations(): """ Parse a module through the normal stub parser stack and verify that nested references to names from the current module are rendered as local names. """ parser = stub_parser_from_args(make_args()) module = parser.handle_module(QualifiedName.from_str("impactx.MADXParser"), make_module()) assert module is not None functions = {function.name: function for function in module.functions} tokenize = functions["tokenize"] rendered = Printer(invalid_expr_as_ellipses=False).print_function(tokenize) assert str(tokenize.args[0].annotation) == "list[Token]" assert str(tokenize.returns) == "dict[str, Expression]" assert rendered[0] == ( "def tokenize(tokens: list[Token], expr: Expression | None = None) " "-> dict[str, Expression]:" ) ``` </details>
1 parent 3a2dac9 commit ae80391

17 files changed

Lines changed: 286 additions & 38 deletions

File tree

pybind11_stubgen/parser/mixins/fix.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -487,8 +487,8 @@ def handle_attribute(self, path: QualifiedName, attr: Any) -> Attribute | None:
487487
result = super().handle_attribute(path, attr)
488488
if result is None:
489489
return None
490-
if isinstance(result.annotation, ResolvedType):
491-
result.annotation.name = self._strip_current_module(result.annotation.name)
490+
if result.annotation is not None:
491+
result.annotation = self._strip_current_module_prefix(result.annotation)
492492
return result
493493

494494
def handle_module(
@@ -516,9 +516,31 @@ def parse_annotation_str(
516516
self, annotation_str: str
517517
) -> ResolvedType | InvalidExpression | Value:
518518
result = super().parse_annotation_str(annotation_str)
519-
if isinstance(result, ResolvedType):
520-
result.name = self._strip_current_module(result.name)
521-
return result
519+
return self._strip_current_module_prefix(result)
520+
521+
def _strip_current_module_prefix(
522+
self, annotation: ResolvedType | InvalidExpression | Value
523+
) -> ResolvedType | InvalidExpression | Value:
524+
"""
525+
Strip the current module prefix from all resolved type names in an
526+
annotation tree.
527+
528+
Python may evaluate local annotations such as ``list[Token]`` into
529+
runtime generics like ``list[mymodule.MyClass.Token]``. The outer
530+
container type (``list`` / ``typing.Optional`` / ``dict``) is valid,
531+
but nested parameters that point back into the current module should be
532+
rendered as local names in the generated stub.
533+
"""
534+
if not isinstance(annotation, ResolvedType):
535+
return annotation
536+
537+
annotation.name = self._strip_current_module(annotation.name)
538+
if annotation.parameters is not None:
539+
annotation.parameters = [
540+
self._strip_current_module_prefix(parameter)
541+
for parameter in annotation.parameters
542+
]
543+
return annotation
522544

523545
def _strip_current_module(self, name: QualifiedName) -> QualifiedName:
524546
if name[: len(self.__current_module)] == self.__current_module:

pybind11_stubgen/parser/mixins/parse.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
import sys
77
import types
8+
import typing
89
from typing import Any, Callable, TypeVar
910

1011
from pybind11_stubgen.parser.errors import (
@@ -296,12 +297,12 @@ def handle_function(self, path: QualifiedName, func: Any) -> list[Function]:
296297
func_args[arg_name].annotation = self.parse_annotation_str(
297298
annotation
298299
)
299-
elif not isinstance(annotation, type):
300-
func_args[arg_name].annotation = self.handle_value(annotation)
301300
elif self._is_generic_alias(annotation):
302301
func_args[arg_name].annotation = self.parse_annotation_str(
303302
str(annotation)
304303
)
304+
elif not isinstance(annotation, type):
305+
func_args[arg_name].annotation = self.handle_value(annotation)
305306
else:
306307
func_args[arg_name].annotation = ResolvedType(
307308
name=self.handle_type(annotation),
@@ -335,9 +336,11 @@ def handle_function(self, path: QualifiedName, func: Any) -> list[Function]:
335336

336337
def _is_generic_alias(self, annotation: type) -> bool:
337338
generic_alias_t: type | None = getattr(types, "GenericAlias", None)
338-
if generic_alias_t is None:
339-
return False
340-
return isinstance(annotation, generic_alias_t)
339+
return (
340+
generic_alias_t is not None
341+
and isinstance(annotation, generic_alias_t)
342+
or typing.get_origin(annotation) is not None
343+
)
341344

342345
def handle_import(self, path: QualifiedName, origin: Any) -> Import | None:
343346
full_name = self._get_full_name(path, origin)

tests/py-demo/demo/pure_python/functions_3_8_plus.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import typing
22

33

4+
class Token:
5+
pass
6+
7+
8+
class Expression:
9+
pass
10+
11+
412
def args_mix(
513
a: int,
614
b: float = 0.5,
@@ -11,3 +19,8 @@ def args_mix(
1119
y=int,
1220
**kwargs: typing.Dict[int, str],
1321
): ...
22+
23+
24+
def nested_current_module_annotations(
25+
tokens: list[Token], expr: typing.Optional[Expression] = None
26+
) -> dict[str, Expression]: ...

tests/stubs/python-3.11/pybind11-v2.11/numpy-array-wrap-with-annotated/demo/pure_python/functions_3_8_plus.pyi

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ from __future__ import annotations
22

33
import typing as typing
44

5-
__all__: list[str] = ["args_mix", "typing"]
5+
__all__: list[str] = [
6+
"Expression",
7+
"Token",
8+
"args_mix",
9+
"nested_current_module_annotations",
10+
"typing",
11+
]
12+
13+
class Token:
14+
pass
15+
16+
class Expression:
17+
pass
618

719
def args_mix(
820
a: int,
@@ -11,5 +23,8 @@ def args_mix(
1123
*args: int,
1224
x: int = 1,
1325
y=int,
14-
**kwargs: typing.Dict[int, str],
26+
**kwargs: dict[int, str],
1527
): ...
28+
def nested_current_module_annotations(
29+
tokens: list[Token], expr: Expression | None = None
30+
) -> dict[str, Expression]: ...

tests/stubs/python-3.11/pybind11-v2.12/numpy-array-wrap-with-annotated/demo/pure_python/functions_3_8_plus.pyi

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ from __future__ import annotations
22

33
import typing as typing
44

5-
__all__: list[str] = ["args_mix", "typing"]
5+
__all__: list[str] = [
6+
"Expression",
7+
"Token",
8+
"args_mix",
9+
"nested_current_module_annotations",
10+
"typing",
11+
]
12+
13+
class Token:
14+
pass
15+
16+
class Expression:
17+
pass
618

719
def args_mix(
820
a: int,
@@ -11,5 +23,8 @@ def args_mix(
1123
*args: int,
1224
x: int = 1,
1325
y=int,
14-
**kwargs: typing.Dict[int, str],
26+
**kwargs: dict[int, str],
1527
): ...
28+
def nested_current_module_annotations(
29+
tokens: list[Token], expr: Expression | None = None
30+
) -> dict[str, Expression]: ...

tests/stubs/python-3.11/pybind11-v2.13/numpy-array-use-type-var/demo/pure_python/functions_3_8_plus.pyi

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ from __future__ import annotations
22

33
import typing as typing
44

5-
__all__: list[str] = ["args_mix", "typing"]
5+
__all__: list[str] = [
6+
"Expression",
7+
"Token",
8+
"args_mix",
9+
"nested_current_module_annotations",
10+
"typing",
11+
]
12+
13+
class Token:
14+
pass
15+
16+
class Expression:
17+
pass
618

719
def args_mix(
820
a: int,
@@ -11,5 +23,8 @@ def args_mix(
1123
*args: int,
1224
x: int = 1,
1325
y=int,
14-
**kwargs: typing.Dict[int, str],
26+
**kwargs: dict[int, str],
1527
): ...
28+
def nested_current_module_annotations(
29+
tokens: list[Token], expr: Expression | None = None
30+
) -> dict[str, Expression]: ...

tests/stubs/python-3.11/pybind11-v2.13/numpy-array-wrap-with-annotated/demo/pure_python/functions_3_8_plus.pyi

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ from __future__ import annotations
22

33
import typing as typing
44

5-
__all__: list[str] = ["args_mix", "typing"]
5+
__all__: list[str] = [
6+
"Expression",
7+
"Token",
8+
"args_mix",
9+
"nested_current_module_annotations",
10+
"typing",
11+
]
12+
13+
class Token:
14+
pass
15+
16+
class Expression:
17+
pass
618

719
def args_mix(
820
a: int,
@@ -11,5 +23,8 @@ def args_mix(
1123
*args: int,
1224
x: int = 1,
1325
y=int,
14-
**kwargs: typing.Dict[int, str],
26+
**kwargs: dict[int, str],
1527
): ...
28+
def nested_current_module_annotations(
29+
tokens: list[Token], expr: Expression | None = None
30+
) -> dict[str, Expression]: ...

tests/stubs/python-3.11/pybind11-v2.9/numpy-array-wrap-with-annotated/demo/pure_python/functions_3_8_plus.pyi

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ from __future__ import annotations
22

33
import typing as typing
44

5-
__all__: list[str] = ["args_mix", "typing"]
5+
__all__: list[str] = [
6+
"Expression",
7+
"Token",
8+
"args_mix",
9+
"nested_current_module_annotations",
10+
"typing",
11+
]
12+
13+
class Token:
14+
pass
15+
16+
class Expression:
17+
pass
618

719
def args_mix(
820
a: int,
@@ -11,5 +23,8 @@ def args_mix(
1123
*args: int,
1224
x: int = 1,
1325
y=int,
14-
**kwargs: typing.Dict[int, str],
26+
**kwargs: dict[int, str],
1527
): ...
28+
def nested_current_module_annotations(
29+
tokens: list[Token], expr: Expression | None = None
30+
) -> dict[str, Expression]: ...

tests/stubs/python-3.11/pybind11-v3.0/numpy-array-use-type-var/demo/pure_python/functions_3_8_plus.pyi

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ from __future__ import annotations
22

33
import typing as typing
44

5-
__all__: list[str] = ["args_mix", "typing"]
5+
__all__: list[str] = [
6+
"Expression",
7+
"Token",
8+
"args_mix",
9+
"nested_current_module_annotations",
10+
"typing",
11+
]
12+
13+
class Token:
14+
pass
15+
16+
class Expression:
17+
pass
618

719
def args_mix(
820
a: int,
@@ -11,5 +23,8 @@ def args_mix(
1123
*args: int,
1224
x: int = 1,
1325
y=int,
14-
**kwargs: typing.Dict[int, str],
26+
**kwargs: dict[int, str],
1527
): ...
28+
def nested_current_module_annotations(
29+
tokens: list[Token], expr: Expression | None = None
30+
) -> dict[str, Expression]: ...

tests/stubs/python-3.11/pybind11-v3.0/numpy-array-wrap-with-annotated/demo/pure_python/functions_3_8_plus.pyi

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ from __future__ import annotations
22

33
import typing as typing
44

5-
__all__: list[str] = ["args_mix", "typing"]
5+
__all__: list[str] = [
6+
"Expression",
7+
"Token",
8+
"args_mix",
9+
"nested_current_module_annotations",
10+
"typing",
11+
]
12+
13+
class Token:
14+
pass
15+
16+
class Expression:
17+
pass
618

719
def args_mix(
820
a: int,
@@ -11,5 +23,8 @@ def args_mix(
1123
*args: int,
1224
x: int = 1,
1325
y=int,
14-
**kwargs: typing.Dict[int, str],
26+
**kwargs: dict[int, str],
1527
): ...
28+
def nested_current_module_annotations(
29+
tokens: list[Token], expr: Expression | None = None
30+
) -> dict[str, Expression]: ...

0 commit comments

Comments
 (0)