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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ class
+ Class :
+
+ View Source
+
+
+
+ 13 class 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ def
+ meth (self , y : bool ) -> bool :
+
+ View Source
+
+
+
+
11 def meth ( self , y : bool ) -> bool : ...
+
+
+
+
+
+
+
+
+
+
+
+ def
+ no_type_annotation (self , z ):
+
+ View Source
+
+
+
+
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 ):
+
+ View Source
+
+
+
+
2625 def _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." )
+
+
+
+
+
+
+
+
+
+
+
+
+ class
+ Class.Subclass :
+
+ View Source
+
+
+
+ 20 class Subclass :
+21 attr = "42"
+22 """An attribute"""
+23
+24 def meth ( self , y ):
+25 """A simple method."""
+
+
+
+
+
+
+
+ attr : str
+
+
+
+
+
+
+
+
+
+
+
+
+
+ def
+ meth (self , y : bool ) -> bool :
+
+ View Source
+
+
+
+
16 def meth ( self , y : bool ) -> bool : ...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
-
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 @@
- 2625 def _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 @@
- 7 def bar ( x : float , y : float , z : float ) -> int : ...
+ 5 def bar ( x , y , z ):
+6 """docstring in py file"""
+7 ...
@@ -296,8 +295,7 @@
- 4 def foo ( x : int , y : str , z : bool ) -> None :
-5 """docstring in stub file"""
+
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