From 0be228a492af0dde2229eec50d98639f03140f1d Mon Sep 17 00:00:00 2001 From: inventshah <39803835+inventshah@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:49:30 -0500 Subject: [PATCH 1/3] support patching docs from imported module stub files --- pdoc/doc.py | 2 + pdoc/doc_pyi.py | 47 +++ test/test_snapshot.py | 1 + test/testdata/type_stubs_from_import.html | 329 ++++++++++++++++++ test/testdata/type_stubs_from_import.txt | 17 + .../type_stubs_from_import/__init__.py | 6 + test/testdata/type_stubs_from_import/_lib.py | 30 ++ test/testdata/type_stubs_from_import/_lib.pyi | 20 ++ test/testdata/typed_dict.html | 5 +- test/testdata/typed_dict.txt | 2 +- 10 files changed, 454 insertions(+), 5 deletions(-) create mode 100644 test/testdata/type_stubs_from_import.html create mode 100644 test/testdata/type_stubs_from_import.txt create mode 100644 test/testdata/type_stubs_from_import/__init__.py create mode 100644 test/testdata/type_stubs_from_import/_lib.py create mode 100644 test/testdata/type_stubs_from_import/_lib.pyi diff --git a/pdoc/doc.py b/pdoc/doc.py index 41c0d673..8a9112b6 100644 --- a/pdoc/doc.py +++ b/pdoc/doc.py @@ -701,6 +701,8 @@ def _declarations(self) -> dict[str, tuple[str, str]]: decls.setdefault(name, (cls.__module__, f"{cls.__qualname__}.{name}")) for name in cls.__dict__: decls.setdefault(name, (cls.__module__, f"{cls.__qualname__}.{name}")) + for name in _safe_getattr(cls, "__annotations__", []): + decls.setdefault(name, (cls.__module__, f"{cls.__qualname__}.{name}")) if decls.get("__init__", None) == ("builtins", "object.__init__"): decls["__init__"] = ( self.obj.__module__, diff --git a/pdoc/doc_pyi.py b/pdoc/doc_pyi.py index d4169262..cdfaa0b9 100644 --- a/pdoc/doc_pyi.py +++ b/pdoc/doc_pyi.py @@ -44,6 +44,7 @@ def find_stub_file(module_name: str) -> Path | None: return None +@cache def _import_stub_file(module_name: str, stub_file: Path) -> types.ModuleType: """ Import the type stub outside of the normal import machinery. @@ -81,6 +82,51 @@ def _prepare_module(ns: doc.Namespace) -> None: _prepare_module(member) +def _patch_doc_from_stub_imports(target_doc: doc.Doc) -> doc.Doc: + """ + Patch members of a target doc (a "real" Python module, e.g. a ".py" file) + with the type information from original stub files (e.g., a ".pyi" file). + + Handles cases such as + ``` + # __init__.py + from ._lib import foo as foo + + # _lib.py + def foo(): ... + + # _lib.pyi + def foo() -> str: ... + ``` + """ + if isinstance(target_doc, doc.Namespace): + for name, member in target_doc.members.items(): + target_doc.members[name] = _patch_doc_from_stub_imports(member) + return target_doc + + modulename, identifier = target_doc.taken_from + stub_file = find_stub_file(modulename) + if stub_file is None: + return target_doc + + imported_stub = _import_stub_file(modulename, stub_file) + + stub_doc = doc.Module(imported_stub).get(identifier) + if stub_doc is None: + return target_doc + + # pyi files have functions where all defs have @overload. + # We don't want to pick up the docstring from the typing helper. + if stub_doc.docstring == overload_docstr: + stub_doc.docstring = "" + + # since stubs are mainly for types, they may be missing a docstring + # in which case we want to pull from the original target + stub_doc.docstring = stub_doc.docstring or target_doc.docstring + + return stub_doc + + def _patch_doc(target_doc: doc.Doc, stub_mod: doc.Module) -> None: """ Patch the target doc (a "real" Python module, e.g. a ".py" file) @@ -127,6 +173,7 @@ def include_typeinfo_from_stub_files(module: doc.Module) -> None: stub_file = find_stub_file(module.modulename) if not stub_file: + _patch_doc_from_stub_imports(module) return try: diff --git a/test/test_snapshot.py b/test/test_snapshot.py index 54f58ad8..218ed59a 100755 --- a/test/test_snapshot.py +++ b/test/test_snapshot.py @@ -158,6 +158,7 @@ def outfile(self, format: str) -> Path: Snapshot("type_checking_imports", ["type_checking_imports.main"]), Snapshot("typed_dict", min_version=(3, 13)), Snapshot("type_stubs", ["type_stubs"], min_version=(3, 10)), + Snapshot("type_stubs_from_import", ["type_stubs_from_import"], min_version=(3, 10)), Snapshot( "visibility", render_options={ diff --git a/test/testdata/type_stubs_from_import.html b/test/testdata/type_stubs_from_import.html new file mode 100644 index 00000000..acd7d146 --- /dev/null +++ b/test/testdata/type_stubs_from_import.html @@ -0,0 +1,329 @@ + + + + + + + type_stubs_from_import API documentation + + + + + + + + + +
+
+

+type_stubs_from_import

+ + + + + + +
1from ._lib import Class
+2from ._lib import bar
+3from ._lib import foo
+4from ._lib import not_in_pyi  # type: ignore[attr-defined]
+5
+6__all__ = ["Class", "bar", "foo", "not_in_pyi"]
+
+ + +
+
+ +
+ + class + Class: + + + +
+ +
13class Class:
+14    attr = 42
+15    """An attribute"""
+16
+17    def meth(self, y):
+18        """A simple method."""
+19
+20    class Subclass:
+21        attr = "42"
+22        """An attribute"""
+23
+24        def meth(self, y):
+25            """A simple method."""
+26
+27    def no_type_annotation(self, z):
+28        """A method not present in the .pyi file."""
+29
+30    def overloaded(self, x):
+31        """An overloaded method."""
+
+ + + + +
+
+ attr: int + + +
+ + +

An attribute

+
+ + +
+
+ +
+ + def + meth(self, y: bool) -> bool: + + + +
+ +
11    def meth(self, y: bool) -> bool: ...
+
+ + +

A simple method.

+
+ + +
+
+ +
+ + def + no_type_annotation(self, z): + + + +
+ +
27    def no_type_annotation(self, z):
+28        """A method not present in the .pyi file."""
+
+ + +

A method not present in the .pyi file.

+
+ + +
+
+ +
+ + def + overloaded(*args, **kwds): + + + +
+ +
2625def _overload_dummy(*args, **kwds):
+2626    """Helper for @overload to raise when called."""
+2627    raise NotImplementedError(
+2628        "You should not call an overloaded function. "
+2629        "A series of @overload-decorated functions "
+2630        "outside a stub module should always be followed "
+2631        "by an implementation that is not @overload-ed.")
+
+ + +

An overloaded method.

+
+ + +
+
+
+ +
+ + class + Class.Subclass: + + + +
+ +
20    class Subclass:
+21        attr = "42"
+22        """An attribute"""
+23
+24        def meth(self, y):
+25            """A simple method."""
+
+ + + + +
+
+ attr: str + + +
+ + +

An attribute

+
+ + +
+
+ +
+ + def + meth(self, y: bool) -> bool: + + + +
+ +
16        def meth(self, y: bool) -> bool: ...
+
+ + +

A simple method.

+
+ + +
+
+
+ +
+ + def + bar(x: float, y: float, z: float) -> int: + + + +
+ +
7def bar(x: float, y: float, z: float) -> int: ...
+
+ + +

docstring in py file

+
+ + +
+
+ +
+ + def + foo(x: int, y: str, z: bool) -> None: + + + +
+ +
4def foo(x: int, y: str, z: bool) -> None:
+5    """docstring in stub file"""
+
+ + +

docstring in stub file

+
+ + +
+
+ +
+ + def + not_in_pyi(x: int) -> str: + + + +
+ +
10def not_in_pyi(x: int) -> str: ...
+
+ + + + +
+
+ + \ No newline at end of file diff --git a/test/testdata/type_stubs_from_import.txt b/test/testdata/type_stubs_from_import.txt new file mode 100644 index 00000000..c09c4cf2 --- /dev/null +++ b/test/testdata/type_stubs_from_import.txt @@ -0,0 +1,17 @@ + + + bool: ... # A simple method.> + + + bool: ... # A simple method.> + > + + + > + int: ... # docstring in py file…> + None: ... # docstring in stub fi…> + str: ... # inherited from type_stubs_from_import._lib.not_in_pyi> +> \ No newline at end of file diff --git a/test/testdata/type_stubs_from_import/__init__.py b/test/testdata/type_stubs_from_import/__init__.py new file mode 100644 index 00000000..64eef30b --- /dev/null +++ b/test/testdata/type_stubs_from_import/__init__.py @@ -0,0 +1,6 @@ +from ._lib import Class +from ._lib import bar +from ._lib import foo +from ._lib import not_in_pyi # type: ignore[attr-defined] + +__all__ = ["Class", "bar", "foo", "not_in_pyi"] diff --git a/test/testdata/type_stubs_from_import/_lib.py b/test/testdata/type_stubs_from_import/_lib.py new file mode 100644 index 00000000..df57d762 --- /dev/null +++ b/test/testdata/type_stubs_from_import/_lib.py @@ -0,0 +1,30 @@ +def foo(x, y, z): ... + + +def bar(x, y, z): + """docstring in py file""" + ... + + +def not_in_pyi(x: int) -> str: ... + + +class Class: + attr = 42 + """An attribute""" + + def meth(self, y): + """A simple method.""" + + class Subclass: + attr = "42" + """An attribute""" + + def meth(self, y): + """A simple method.""" + + def no_type_annotation(self, z): + """A method not present in the .pyi file.""" + + def overloaded(self, x): + """An overloaded method.""" diff --git a/test/testdata/type_stubs_from_import/_lib.pyi b/test/testdata/type_stubs_from_import/_lib.pyi new file mode 100644 index 00000000..7beaea3d --- /dev/null +++ b/test/testdata/type_stubs_from_import/_lib.pyi @@ -0,0 +1,20 @@ +from typing import overload + +def foo(x: int, y: str, z: bool) -> None: + """docstring in stub file""" + +def bar(x: float, y: float, z: float) -> int: ... + +class Class: + attr: int + def meth(self, y: bool) -> bool: ... + + class Subclass: + attr: str + + def meth(self, y: bool) -> bool: ... + + @overload + def overloaded(self, x: int) -> int: ... + @overload + def overloaded(self, x: str) -> str: ... diff --git a/test/testdata/typed_dict.html b/test/testdata/typed_dict.html index f823d3cd..c75d7f40 100644 --- a/test/testdata/typed_dict.html +++ b/test/testdata/typed_dict.html @@ -234,10 +234,7 @@
Inherited Members
Bar
b
c
- -
-
Foo
-
a
+
a
diff --git a/test/testdata/typed_dict.txt b/test/testdata/typed_dict.txt index 637246e2..8f23b1ab 100644 --- a/test/testdata/typed_dict.txt +++ b/test/testdata/typed_dict.txt @@ -32,7 +32,7 @@ - + From ca65ac7092b0b0513d134dd594e343d407ffd54d Mon Sep 17 00:00:00 2001 From: inventshah <39803835+inventshah@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:21:30 -0500 Subject: [PATCH 2/3] fix handling of overload for <3.14 tests --- pdoc/doc_pyi.py | 5 +++++ test/testdata/type_stubs_from_import.html | 22 ++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/pdoc/doc_pyi.py b/pdoc/doc_pyi.py index cdfaa0b9..861cccb8 100644 --- a/pdoc/doc_pyi.py +++ b/pdoc/doc_pyi.py @@ -120,6 +120,11 @@ def foo() -> str: ... if stub_doc.docstring == overload_docstr: stub_doc.docstring = "" + # always pull source information from a real py module if available + stub_doc.source = target_doc.source or stub_doc.source + stub_doc.source_file = target_doc.source_file or stub_doc.source_file + stub_doc.source_lines = target_doc.source_lines or stub_doc.source_lines + # since stubs are mainly for types, they may be missing a docstring # in which case we want to pull from the original target stub_doc.docstring = stub_doc.docstring or target_doc.docstring diff --git a/test/testdata/type_stubs_from_import.html b/test/testdata/type_stubs_from_import.html index acd7d146..e3f44e7b 100644 --- a/test/testdata/type_stubs_from_import.html +++ b/test/testdata/type_stubs_from_import.html @@ -152,7 +152,8 @@

-
11    def meth(self, y: bool) -> bool: ...
+            
17    def meth(self, y):
+18        """A simple method."""
 
@@ -193,13 +194,8 @@

-
2625def _overload_dummy(*args, **kwds):
-2626    """Helper for @overload to raise when called."""
-2627    raise NotImplementedError(
-2628        "You should not call an overloaded function. "
-2629        "A series of @overload-decorated functions "
-2630        "outside a stub module should always be followed "
-2631        "by an implementation that is not @overload-ed.")
+            
30    def overloaded(self, x):
+31        """An overloaded method."""
 
@@ -255,7 +251,8 @@

-
16        def meth(self, y: bool) -> bool: ...
+            
24        def meth(self, y):
+25            """A simple method."""
 
@@ -276,7 +273,9 @@

-
7def bar(x: float, y: float, z: float) -> int: ...
+            
5def bar(x, y, z):
+6    """docstring in py file"""
+7    ...
 
@@ -296,8 +295,7 @@

-
4def foo(x: int, y: str, z: bool) -> None:
-5    """docstring in stub file"""
+            
2def foo(x, y, z): ...
 
From fc47c1b6c4ee07c8e053fcc99959cd70aa0b5565 Mon Sep 17 00:00:00 2001 From: inventshah <39803835+inventshah@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:56:53 -0500 Subject: [PATCH 3/3] fix test_smoke[pydantic] --- pdoc/_pydantic.py | 6 +++++- pdoc/doc_pyi.py | 8 +++++++- test/test_doc_pyi.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pdoc/_pydantic.py b/pdoc/_pydantic.py index 945537a5..351dab02 100644 --- a/pdoc/_pydantic.py +++ b/pdoc/_pydantic.py @@ -31,7 +31,11 @@ def is_pydantic_model(obj: ClassOrModule) -> TypeGuard[pydantic.BaseModel]: # => if we cannot import pydantic, the passed object cannot be a subclass of BaseModel. return False - return isinstance(obj, type) and issubclass(obj, pydantic.BaseModel) + return ( + isinstance(obj, type) + and issubclass(obj, pydantic.BaseModel) + and obj is not pydantic.BaseModel # BaseModel doesn't have __pydantic_fields__ + ) def default_value(parent: ClassOrModule, name: str, obj: Any) -> Any: diff --git a/pdoc/doc_pyi.py b/pdoc/doc_pyi.py index 861cccb8..4c6b260a 100644 --- a/pdoc/doc_pyi.py +++ b/pdoc/doc_pyi.py @@ -109,7 +109,13 @@ def foo() -> str: ... if stub_file is None: return target_doc - imported_stub = _import_stub_file(modulename, stub_file) + try: + imported_stub = _import_stub_file(modulename, stub_file) + except Exception: + warnings.warn( + f"Error parsing type stubs for {modulename}:\n{traceback.format_exc()}" + ) + return target_doc stub_doc = doc.Module(imported_stub).get(identifier) if stub_doc is None: diff --git a/test/test_doc_pyi.py b/test/test_doc_pyi.py index 32df70a9..f7c23961 100644 --- a/test/test_doc_pyi.py +++ b/test/test_doc_pyi.py @@ -36,3 +36,15 @@ def test_invalid_stub_file(monkeypatch): UserWarning, match=r"Error parsing type stubs[\s\S]+RuntimeError" ): _ = doc.Module(doc).members + + +def test_invalid_imported_stub_file(monkeypatch): + monkeypatch.setattr( + doc_pyi, + "find_stub_file", + lambda m: here / "import_err/err/__init__.py" if m == "inspect" else None, + ) + with pytest.warns( + UserWarning, match=r"Error parsing type stubs[\s\S]+RuntimeError" + ): + _ = doc.Module(doc).members