diff --git a/CHANGELOG.md b/CHANGELOG.md index a58dba48..c52ee93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Fix a bug where entire modules would be excluded by `--no-include-undocumented`. To exclude modules, see https://pdoc.dev/docs/pdoc.html#exclude-submodules-from-being-documented. ([#728](https://github.com/mitmproxy/pdoc/pull/728), @mhils) +- Fix a bug where subclasses of TypedDict subclasses would not render correctly. + ([#729](https://github.com/mitmproxy/pdoc/pull/729), @mhils) ## 2024-07-24: pdoc 14.6.0 diff --git a/pdoc/_compat.py b/pdoc/_compat.py index c1271e39..32af1e66 100644 --- a/pdoc/_compat.py +++ b/pdoc/_compat.py @@ -123,6 +123,16 @@ def format_usage(self): return ' | '.join(self.option_strings) +if sys.version_info >= (3, 10): + from typing import is_typeddict +else: # pragma: no cover + def is_typeddict(tp): + try: + return tp.__orig_bases__[-1].__name__ == "TypedDict" + except Exception: + return False + + __all__ = [ "cache", "ast_unparse", @@ -134,4 +144,5 @@ def format_usage(self): "removesuffix", "formatannotation", "BooleanOptionalAction", + "is_typeddict", ] diff --git a/pdoc/doc.py b/pdoc/doc.py index d5ef5666..32730edf 100644 --- a/pdoc/doc.py +++ b/pdoc/doc.py @@ -37,6 +37,7 @@ from typing import Any from typing import ClassVar from typing import Generic +from typing import TypedDict from typing import TypeVar from typing import Union from typing import get_origin @@ -49,6 +50,7 @@ from pdoc._compat import TypeAliasType from pdoc._compat import cache from pdoc._compat import formatannotation +from pdoc._compat import is_typeddict from pdoc.doc_types import GenericAlias from pdoc.doc_types import NonUserDefinedCallables from pdoc.doc_types import empty @@ -655,14 +657,21 @@ def _var_annotations(self) -> dict[str, type]: @cached_property def _bases(self) -> tuple[type, ...]: orig_bases = _safe_getattr(self.obj, "__orig_bases__", ()) - old_python_typeddict_workaround = ( - sys.version_info < (3, 12) - and orig_bases - and _safe_getattr(orig_bases[-1], "__name__", None) == "TypedDict" - ) - if old_python_typeddict_workaround: # pragma: no cover - # TypedDicts on Python <3.12 have a botched __mro__. We need to fix it. - return (self.obj, *orig_bases[:-1]) + + if is_typeddict(self.obj): + if sys.version_info < (3, 12): # pragma: no cover + # TypedDicts on Python <3.12 have a botched __mro__. We need to fix it. + return (self.obj, *orig_bases[:-1]) + else: + # TypedDict on Python >=3.12 removes intermediate classes from __mro__, + # so we use orig_bases to recover the full mro. + while orig_bases and orig_bases[-1] is not TypedDict: + parent_bases = _safe_getattr(orig_bases[-1], "__orig_bases__", ()) + if ( + len(parent_bases) != 1 or parent_bases in orig_bases + ): # sanity check that things look right + break # pragma: no cover + orig_bases = (*orig_bases, parent_bases[0]) # __mro__ and __orig_bases__ differ between Python versions and special cases like TypedDict/NamedTuple. # This here is a pragmatic approximation of what we want. diff --git a/test/test_doc_types.py b/test/test_doc_types.py index 63b47ad7..2bd4dd81 100644 --- a/test/test_doc_types.py +++ b/test/test_doc_types.py @@ -79,5 +79,7 @@ def get_source(mod): monkeypatch.setitem(sys.modules, "a", a) monkeypatch.setitem(sys.modules, "b", b) - with pytest.warns(UserWarning, match="Recursion error when importing a"): + with pytest.warns( + UserWarning, match="Recursion error when importing a|Import of xyz failed" + ): assert safe_eval_type("xyz", a.__dict__, None, a, "a") == "xyz" diff --git a/test/testdata/misc_py312.html b/test/testdata/misc_py312.html index ca8f227a..2f68782c 100755 --- a/test/testdata/misc_py312.html +++ b/test/testdata/misc_py312.html @@ -71,6 +71,15 @@

API Documentation

+
  • + Baz + + +
  • @@ -148,6 +157,13 @@

    52 """Second attribute.""" 53 c: str 54 # undocumented attribute +55 +56 +57class Baz(Bar): +58 """A TypedDict subsubclass.""" +59 +60 d: bool +61 """new attribute""" @@ -407,6 +423,71 @@

    Inherited Members
    +
    + +
    + + class + Baz(Bar): + + + +
    + +
    58class Baz(Bar):
    +59    """A TypedDict subsubclass."""
    +60
    +61    d: bool
    +62    """new attribute"""
    +
    + + +

    A TypedDict subsubclass.

    +
    + + +
    +
    + d: bool + + +
    + + +

    new attribute

    +
    + + +
    +
    +
    Inherited Members
    +
    +
    Bar
    +
    b
    +
    c
    + +
    +
    Foo
    +
    a
    + +
    +
    builtins.dict
    +
    get
    +
    setdefault
    +
    pop
    +
    popitem
    +
    keys
    +
    items
    +
    values
    +
    update
    +
    fromkeys
    +
    clear
    +
    copy
    + +
    +
    +
    +
    \ No newline at end of file diff --git a/test/testdata/misc_py312.py b/test/testdata/misc_py312.py index 2d7db3fc..21e5a07e 100755 --- a/test/testdata/misc_py312.py +++ b/test/testdata/misc_py312.py @@ -52,3 +52,10 @@ class Bar(Foo, total=False): """Second attribute.""" c: str # undocumented attribute + + +class Baz(Bar): + """A TypedDict subsubclass.""" + + d: bool + """new attribute""" diff --git a/test/testdata/misc_py312.txt b/test/testdata/misc_py312.txt index fc7d488f..6f68ce04 100755 --- a/test/testdata/misc_py312.txt +++ b/test/testdata/misc_py312.txt @@ -40,4 +40,21 @@ None. …> a shallo…> > + + + + + + + v, r…> + + a set-li…> + a set-l…> + an obj…> + + + None. …> + a shallo…> + > > \ No newline at end of file