Skip to content
Open
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
6 changes: 5 additions & 1 deletion pdoc/_pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions pdoc/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__,
Expand Down
58 changes: 58 additions & 0 deletions pdoc/doc_pyi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -81,6 +82,62 @@ 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

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:
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 = ""

# 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

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)
Expand Down Expand Up @@ -127,6 +184,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:
Expand Down
12 changes: 12 additions & 0 deletions test/test_doc_pyi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions test/test_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
327 changes: 327 additions & 0 deletions test/testdata/type_stubs_from_import.html

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions test/testdata/type_stubs_from_import.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<module type_stubs_from_import
<class type_stubs_from_import.Class # inherited from type_stubs_from_import._lib.Class
<method def __init__(): ...>
<var attr: int # An attribute>
<method def meth(self, y: bool) -> bool: ... # A simple method.>
<class type_stubs_from_import.Class.Subclass # inherited from type_stubs_from_import._lib.Class.Subclass
<method def __init__(): ...>
<var attr: str # An attribute>
<method def meth(self, y: bool) -> bool: ... # A simple method.>
>
<method def no_type_annotation(self, z): ... # inherited from type_stubs_from_import._lib.Class.no_type_annotation, A method not present…>
<method def overloaded(*args, **kwds): ... # An overloaded method…>
>
<function def bar(x: float, y: float, z: float) -> int: ... # docstring in py file…>
<function def foo(x: int, y: str, z: bool) -> None: ... # docstring in stub fi…>
<function def not_in_pyi(x: int) -> str: ... # inherited from type_stubs_from_import._lib.not_in_pyi>
>
6 changes: 6 additions & 0 deletions test/testdata/type_stubs_from_import/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
30 changes: 30 additions & 0 deletions test/testdata/type_stubs_from_import/_lib.py
Original file line number Diff line number Diff line change
@@ -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."""
20 changes: 20 additions & 0 deletions test/testdata/type_stubs_from_import/_lib.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
5 changes: 1 addition & 4 deletions test/testdata/typed_dict.html
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,7 @@ <h5>Inherited Members</h5>
<div><dt><a href="#Bar">Bar</a></dt>
<dd id="Baz.b" class="variable"><a href="#Bar.b">b</a></dd>
<dd id="Baz.c" class="variable"><a href="#Bar.c">c</a></dd>

</div>
<div><dt><a href="#Foo">Foo</a></dt>
<dd id="Baz.a" class="variable"><a href="#Foo.a">a</a></dd>
<dd id="Baz.a" class="variable"><a href="#Bar.a">a</a></dd>

</div>
</dl>
Expand Down
2 changes: 1 addition & 1 deletion test/testdata/typed_dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<class typed_dict.Baz # A TypedDict subsubcl…
<var b: int # inherited from typed_dict.Bar.b, Second attribute.>
<var c: str # inherited from typed_dict.Bar.c>
<var a: int | None # inherited from typed_dict.Foo.a, First attribute.>
<var a: int | None # inherited from typed_dict.Bar.a, First attribute.>
<var d: bool # new attribute>
<method def get(self, key, default=None, /): ... # inherited from builtins.dict.get, Return the value for…>
<method def setdefault(self, key, default=None, /): ... # inherited from builtins.dict.setdefault, Insert key with a va…>
Expand Down