Skip to content

Commit 3f75834

Browse files
jinnovationautofix-ci[bot]mhils
authored
Support Pydantic model defaults + field descriptions (#802)
* Support defaults from Pydantic fields * gate specifically on pydantic * do not include importerror block in test coverage * extend pragma no cover * WIP: Convert to snapshot test * remove dataclass import * create _pydantic module * [autofix.ci] apply automated fixes * fix lint * fix importlib import * mark generated files * suppress BaseModel fields * harden * update snapshot * add note * changelog * [autofix.ci] apply automated fixes * add docs * [autofix.ci] apply automated fixes * undo gitattributes * Update pdoc/__init__.py Co-authored-by: Maximilian Hils <[email protected]> * render docstring * [autofix.ci] apply automated fixes * _pydantic.skip_field * [autofix.ci] apply automated fixes * fix lint * refining pydantic-installed detection logic * fix lint * support 3.9 type checking * expand type annotations * cleanup * [autofix.ci] apply automated fixes * typecast * move field exclusion to `Class._member_objects` * rm defunct fn * [autofix.ci] apply automated fixes * simplify * refine type annotations * reuse is_pydantic_model logic * simplify * use frozenset * rm unused * [autofix.ci] apply automated fixes * is_pydantic_model return False if pydantic not installed * simplify (ish) conditional import logic * simplify field-pruning * localize more logic to _pydantic * import TypeGuard from typing_extensions * simplify docstring logic * Update pdoc/doc.py * simplify * Update pdoc/_pydantic.py Co-authored-by: Maximilian Hils <[email protected]> * Revert "import TypeGuard from typing_extensions" This reverts commit efaafcd. * add typing.cast back in * simplify * fix nits * update lockfile * add BaseModel test --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maximilian Hils <[email protected]> Co-authored-by: Maximilian Hils <[email protected]>
1 parent f19fbf7 commit 3f75834

File tree

11 files changed

+830
-286
lines changed

11 files changed

+830
-286
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
upstream](https://github.com/trentm/python-markdown2)
1818
- Add support for keyword args for Google flavor docs.
1919
([#840](https://github.com/mitmproxy/pdoc/pull/840), @aleksslitvinovs)
20+
- Add support for Pydantic-style field docstrings,
21+
e.g. `pydantic.Field(description="...")`
22+
([#802](https://github.com/mitmproxy/pdoc/pull/802), @jinnovation)
2023

2124
## 2025-06-04: pdoc 15.0.4
2225

pdoc/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,25 @@ class GoldenRetriever(Dog):
260260
Adding additional syntax elements is usually easy. If you feel that pdoc doesn't parse a docstring element properly,
261261
please amend `pdoc.docstrings` and send us a pull request!
262262
263+
## ...document Pydantic models?
264+
265+
For [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/), pdoc
266+
will extract [field](https://docs.pydantic.dev/latest/concepts/fields/)
267+
descriptions and treat them just like [documented
268+
variables](#document-variables). For example, the following two Pydantic models
269+
would have identical pdoc-rendered documentation:
270+
271+
```python
272+
from pydantic import BaseModel, Field
273+
274+
class Foo(BaseModel):
275+
a: int = Field(description="Docs for field a.")
276+
277+
class OtherFoo(BaseModel):
278+
a: int
279+
"""Docs for field a."""
280+
281+
```
263282
264283
## ...render math formulas?
265284

pdoc/_pydantic.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Work with Pydantic models."""
2+
3+
from __future__ import annotations
4+
5+
import types
6+
from typing import Any
7+
from typing import TypeAlias
8+
from typing import TypeGuard
9+
10+
try:
11+
import pydantic
12+
except ImportError: # pragma: no cover
13+
pydantic = None # type: ignore
14+
15+
ClassOrModule: TypeAlias = type | types.ModuleType
16+
"""Type alias for the type of `Namespace.obj`."""
17+
18+
IGNORED_FIELDS: frozenset[str] = frozenset(
19+
(["__fields__"] + list(pydantic.BaseModel.__dict__.keys()))
20+
if pydantic is not None
21+
else []
22+
)
23+
"""Fields to ignore when generating docs, e.g. those that emit deprecation
24+
warnings or that are not relevant to users of BaseModel-derived classes."""
25+
26+
27+
def is_pydantic_model(obj: ClassOrModule) -> TypeGuard[pydantic.BaseModel]:
28+
"""Returns whether an object is a Pydantic model."""
29+
if pydantic is None: # pragma: no cover
30+
# classes that subclass pydantic.BaseModel can only be instantiated if pydantic is importable
31+
# => if we cannot import pydantic, the passed object cannot be a subclass of BaseModel.
32+
return False
33+
34+
return isinstance(obj, type) and issubclass(obj, pydantic.BaseModel)
35+
36+
37+
def default_value(parent: ClassOrModule, name: str, obj: Any) -> Any:
38+
"""Determine the default value of obj.
39+
40+
For pydantic BaseModels, extract the default value from field metadata.
41+
For all other objects, return `obj` as-is.
42+
"""
43+
if is_pydantic_model(parent):
44+
pydantic_fields = parent.__pydantic_fields__
45+
return pydantic_fields[name].default if name in pydantic_fields else obj
46+
47+
return obj
48+
49+
50+
def get_field_docstring(parent: ClassOrModule, field_name: str) -> str | None:
51+
if is_pydantic_model(parent):
52+
if field := parent.__pydantic_fields__.get(field_name, None):
53+
return field.description
54+
return None

pdoc/doc.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@
4141
from typing import TypeAlias
4242
from typing import TypedDict
4343
from typing import TypeVar
44+
from typing import cast
4445
from typing import get_origin
4546
from typing import is_typeddict
4647
import warnings
4748

49+
from pdoc import _pydantic
4850
from pdoc import doc_ast
4951
from pdoc import doc_pyi
5052
from pdoc import extract
@@ -209,7 +211,10 @@ def __lt__(self, other):
209211
)
210212

211213

212-
class Namespace(Doc[T], metaclass=ABCMeta):
214+
U = TypeVar("U", bound=types.ModuleType | type)
215+
216+
217+
class Namespace(Doc[U], metaclass=ABCMeta):
213218
"""
214219
A documentation object that can have children. In other words, either a module or a class.
215220
"""
@@ -318,13 +323,17 @@ def members(self) -> dict[str, Doc]:
318323
qualname,
319324
docstring="",
320325
annotation=self._var_annotations.get(name, empty),
321-
default_value=obj,
326+
default_value=_pydantic.default_value(self.obj, name, obj),
322327
taken_from=taken_from,
323328
)
324-
if self._var_docstrings.get(name):
329+
330+
if _doc := _pydantic.get_field_docstring(cast(type, self.obj), name):
331+
doc.docstring = _doc
332+
elif self._var_docstrings.get(name):
325333
doc.docstring = self._var_docstrings[name]
326-
if self._func_docstrings.get(name) and not doc.docstring:
334+
elif self._func_docstrings.get(name) and not doc.docstring:
327335
doc.docstring = self._func_docstrings[name]
336+
328337
members[doc.name] = doc
329338

330339
if isinstance(self, Module):
@@ -774,6 +783,12 @@ def _member_objects(self) -> dict[str, Any]:
774783
for cls in self._bases:
775784
sorted, unsorted = doc_ast.sort_by_source(cls, sorted, unsorted)
776785
sorted.update(unsorted)
786+
787+
if _pydantic.is_pydantic_model(self.obj):
788+
sorted = {
789+
k: v for k, v in sorted.items() if k not in _pydantic.IGNORED_FIELDS
790+
}
791+
777792
return sorted
778793

779794
@cached_property
@@ -904,7 +919,7 @@ def __init__(
904919
else:
905920
unwrapped = func
906921
super().__init__(modulename, qualname, unwrapped, taken_from)
907-
self.wrapped = func
922+
self.wrapped = func # type: ignore
908923

909924
@cache
910925
@_include_fullname_in_traceback

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dev-dependencies = [
5252
"pytest-timeout>=2.3.1",
5353
"hypothesis>=6.113.0",
5454
"pdoc-pyo3-sample-library>=1.0.11",
55+
"pydantic>=2.12.0",
5556
]
5657

5758
[build-system]
@@ -103,6 +104,11 @@ select = ["E", "F", "I"]
103104
ignore = ["E501"]
104105
isort = { force-single-line = true, force-sort-within-sections = true }
105106

107+
[tool.ruff.per-file-target-version]
108+
"test/testdata/misc_py312.py" = "py312"
109+
"test/testdata/misc_py313.py" = "py313"
110+
"test/testdata/misc_py314.py" = "py314"
111+
106112
[tool.tox]
107113
legacy_tox_ini = """
108114
[tox]

test/test__pydantic.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import pydantic
2+
3+
from pdoc import _pydantic
4+
import pdoc.doc
5+
6+
7+
def test_no_pydantic(monkeypatch):
8+
monkeypatch.setattr(_pydantic, "pydantic", None)
9+
10+
assert not _pydantic.is_pydantic_model(pdoc.doc.Module)
11+
assert _pydantic.get_field_docstring(pdoc.doc.Module, "kind") is None
12+
assert _pydantic.default_value(pdoc.doc.Module, "kind", "module") == "module"
13+
14+
15+
def test_with_pydantic(monkeypatch):
16+
class User(pydantic.BaseModel):
17+
id: int
18+
name: str = pydantic.Field(description="desc", default="Jane Doe")
19+
20+
assert _pydantic.is_pydantic_model(User)
21+
assert _pydantic.get_field_docstring(User, "name") == "desc"
22+
assert _pydantic.default_value(User, "name", None) == "Jane Doe"
23+
24+
assert not _pydantic.is_pydantic_model(pdoc.doc.Module)
25+
assert _pydantic.get_field_docstring(pdoc.doc.Module, "kind") is None
26+
assert _pydantic.default_value(pdoc.doc.Module, "kind", "module") == "module"

test/test_snapshot.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def outfile(self, format: str) -> Path:
164164
"include_undocumented": False,
165165
},
166166
),
167+
Snapshot("with_pydantic"),
167168
]
168169

169170

test/testdata/with_pydantic.html

Lines changed: 143 additions & 0 deletions
Large diffs are not rendered by default.

test/testdata/with_pydantic.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""
2+
A small example with Pydantic entities.
3+
"""
4+
5+
import pydantic
6+
7+
8+
class Foo(pydantic.BaseModel):
9+
"""Foo class documentation."""
10+
11+
model_config = pydantic.ConfigDict(
12+
use_attribute_docstrings=True,
13+
)
14+
15+
a: int = pydantic.Field(default=1, description="Docstring for a")
16+
17+
b: int = 2
18+
"""Docstring for b."""

test/testdata/with_pydantic.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<module with_pydantic # A small example with…
2+
<class with_pydantic.Foo # Foo class documentat…
3+
<var a: int = 1 # Docstring for a>
4+
<var b: int = 2 # Docstring for b.>
5+
>
6+
>

0 commit comments

Comments
 (0)