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 @@
A TypedDict subsubclass.
+