Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve typeddict bases recursively, fix #726 #729

Merged
merged 2 commits into from
Aug 13, 2024
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions pdoc/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -134,4 +144,5 @@ def format_usage(self):
"removesuffix",
"formatannotation",
"BooleanOptionalAction",
"is_typeddict",
]
25 changes: 17 additions & 8 deletions pdoc/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion test/test_doc_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
81 changes: 81 additions & 0 deletions test/testdata/misc_py312.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ <h2>API Documentation</h2>
</ul>

</li>
<li>
<a class="class" href="#Baz">Baz</a>
<ul class="memberlist">
<li>
<a class="variable" href="#Baz.d">d</a>
</li>
</ul>

</li>
</ul>


Expand Down Expand Up @@ -148,6 +157,13 @@ <h1 class="modulename">
</span><span id="L-52"><a href="#L-52"><span class="linenos">52</span></a><span class="w"> </span><span class="sd">&quot;&quot;&quot;Second attribute.&quot;&quot;&quot;</span>
</span><span id="L-53"><a href="#L-53"><span class="linenos">53</span></a> <span class="n">c</span><span class="p">:</span> <span class="nb">str</span>
</span><span id="L-54"><a href="#L-54"><span class="linenos">54</span></a> <span class="c1"># undocumented attribute</span>
</span><span id="L-55"><a href="#L-55"><span class="linenos">55</span></a>
</span><span id="L-56"><a href="#L-56"><span class="linenos">56</span></a>
</span><span id="L-57"><a href="#L-57"><span class="linenos">57</span></a><span class="k">class</span> <span class="nc">Baz</span><span class="p">(</span><span class="n">Bar</span><span class="p">):</span>
</span><span id="L-58"><a href="#L-58"><span class="linenos">58</span></a><span class="w"> </span><span class="sd">&quot;&quot;&quot;A TypedDict subsubclass.&quot;&quot;&quot;</span>
</span><span id="L-59"><a href="#L-59"><span class="linenos">59</span></a>
</span><span id="L-60"><a href="#L-60"><span class="linenos">60</span></a> <span class="n">d</span><span class="p">:</span> <span class="nb">bool</span>
</span><span id="L-61"><a href="#L-61"><span class="linenos">61</span></a><span class="w"> </span><span class="sd">&quot;&quot;&quot;new attribute&quot;&quot;&quot;</span>
</span></pre></div>


Expand Down Expand Up @@ -407,6 +423,71 @@ <h5>Inherited Members</h5>
</dl>
</div>
</section>
<section id="Baz">
<input id="Baz-view-source" class="view-source-toggle-state" type="checkbox" aria-hidden="true" tabindex="-1">
<div class="attr class">

<span class="def">class</span>
<span class="name">Baz</span><wbr>(<span class="base"><a href="#Bar">Bar</a></span>):

<label class="view-source-button" for="Baz-view-source"><span>View Source</span></label>

</div>
<a class="headerlink" href="#Baz"></a>
<div class="pdoc-code codehilite"><pre><span></span><span id="Baz-58"><a href="#Baz-58"><span class="linenos">58</span></a><span class="k">class</span> <span class="nc">Baz</span><span class="p">(</span><span class="n">Bar</span><span class="p">):</span>
</span><span id="Baz-59"><a href="#Baz-59"><span class="linenos">59</span></a><span class="w"> </span><span class="sd">&quot;&quot;&quot;A TypedDict subsubclass.&quot;&quot;&quot;</span>
</span><span id="Baz-60"><a href="#Baz-60"><span class="linenos">60</span></a>
</span><span id="Baz-61"><a href="#Baz-61"><span class="linenos">61</span></a> <span class="n">d</span><span class="p">:</span> <span class="nb">bool</span>
</span><span id="Baz-62"><a href="#Baz-62"><span class="linenos">62</span></a><span class="w"> </span><span class="sd">&quot;&quot;&quot;new attribute&quot;&quot;&quot;</span>
</span></pre></div>


<div class="docstring"><p>A TypedDict subsubclass.</p>
</div>


<div id="Baz.d" class="classattr">
<div class="attr variable">
<span class="name">d</span><span class="annotation">: bool</span>


</div>
<a class="headerlink" href="#Baz.d"></a>

<div class="docstring"><p>new attribute</p>
</div>


</div>
<div class="inherited">
<h5>Inherited Members</h5>
<dl>
<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>

</div>
<div><dt>builtins.dict</dt>
<dd id="Baz.get" class="function">get</dd>
<dd id="Baz.setdefault" class="function">setdefault</dd>
<dd id="Baz.pop" class="function">pop</dd>
<dd id="Baz.popitem" class="function">popitem</dd>
<dd id="Baz.keys" class="function">keys</dd>
<dd id="Baz.items" class="function">items</dd>
<dd id="Baz.values" class="function">values</dd>
<dd id="Baz.update" class="function">update</dd>
<dd id="Baz.fromkeys" class="function">fromkeys</dd>
<dd id="Baz.clear" class="function">clear</dd>
<dd id="Baz.copy" class="function">copy</dd>

</div>
</dl>
</div>
</section>
</main>
</body>
</html>
7 changes: 7 additions & 0 deletions test/testdata/misc_py312.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
17 changes: 17 additions & 0 deletions test/testdata/misc_py312.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,21 @@
<method def clear(unknown): ... # inherited from builtins.dict.clear, D.clear() -> None. …>
<method def copy(unknown): ... # inherited from builtins.dict.copy, D.copy() -> a shallo…>
>
<class misc_py312.Baz # A TypedDict subsubcl…
<var b: int # inherited from misc_py312.Bar.b, Second attribute.>
<var c: str # inherited from misc_py312.Bar.c>
<var a: Optional[int] # inherited from misc_py312.Foo.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…>
<method def pop(unknown): ... # inherited from builtins.dict.pop, D.pop(k[,d]) -> v, r…>
<method def popitem(self, /): ... # inherited from builtins.dict.popitem, Remove and return a …>
<method def keys(unknown): ... # inherited from builtins.dict.keys, D.keys() -> a set-li…>
<method def items(unknown): ... # inherited from builtins.dict.items, D.items() -> a set-l…>
<method def values(unknown): ... # inherited from builtins.dict.values, D.values() -> an obj…>
<method def update(unknown): ... # inherited from builtins.dict.update, D.update([E, ]**F) -…>
<method def fromkeys(type, iterable, value=None, /): ... # inherited from builtins.dict.fromkeys, Create a new diction…>
<method def clear(unknown): ... # inherited from builtins.dict.clear, D.clear() -> None. …>
<method def copy(unknown): ... # inherited from builtins.dict.copy, D.copy() -> a shallo…>
>
>