Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9dcfd25
Support defaults from Pydantic fields
jinnovation May 1, 2025
991910c
gate specifically on pydantic
jinnovation May 2, 2025
b077891
do not include importerror block in test coverage
jinnovation May 2, 2025
19efc29
extend pragma no cover
jinnovation May 2, 2025
7a32d2b
WIP: Convert to snapshot test
jinnovation May 2, 2025
a413dd9
remove dataclass import
jinnovation May 2, 2025
25090dd
create _pydantic module
jinnovation Oct 11, 2025
bad5751
Merge remote-tracking branch 'upstream/main' into pydantic
jinnovation Oct 11, 2025
a2ab163
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 11, 2025
83f467c
fix lint
jinnovation Oct 11, 2025
5381116
fix importlib import
jinnovation Oct 11, 2025
adbe853
mark generated files
jinnovation Oct 11, 2025
10c35b8
suppress BaseModel fields
jinnovation Oct 11, 2025
3206e0f
harden
jinnovation Oct 11, 2025
9f9e5d6
update snapshot
jinnovation Oct 11, 2025
236e97d
add note
jinnovation Oct 11, 2025
b576d73
changelog
jinnovation Oct 11, 2025
db7938d
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 11, 2025
5d44d35
Merge remote-tracking branch 'upstream/main' into pydantic
jinnovation Oct 11, 2025
cdee9e5
add docs
jinnovation Oct 11, 2025
010d26b
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 11, 2025
5182cd0
Merge branch 'main' into pydantic
jinnovation Oct 11, 2025
ee2a783
undo gitattributes
jinnovation Oct 12, 2025
3f26ff7
Update pdoc/__init__.py
jinnovation Oct 12, 2025
fb31c98
render docstring
jinnovation Oct 12, 2025
9327e13
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 12, 2025
1795c40
_pydantic.skip_field
jinnovation Oct 12, 2025
9a19f6e
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 12, 2025
82d54fe
fix lint
jinnovation Oct 12, 2025
976c236
refining pydantic-installed detection logic
jinnovation Oct 12, 2025
3506b48
fix lint
jinnovation Oct 12, 2025
cd271f2
support 3.9 type checking
jinnovation Oct 12, 2025
5fd7462
expand type annotations
jinnovation Oct 12, 2025
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
([#831](https://github.com/mitmproxy/pdoc/issues/831), @iFreilicht)
- Replace vendored version of `markdown2` with the [official
upstream](https://github.com/trentm/python-markdown2)
- Add support for Pydantic-style field docstrings,
e.g. `pydantic.Field(description="...")`
([#802](https://github.com/mitmproxy/pdoc/pull/802), @jinnovation)

## 2025-06-04: pdoc 15.0.4

Expand Down
19 changes: 19 additions & 0 deletions pdoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,25 @@ class GoldenRetriever(Dog):
Adding additional syntax elements is usually easy. If you feel that pdoc doesn't parse a docstring element properly,
please amend `pdoc.docstrings` and send us a pull request!

## ...document Pydantic models?

For [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/), pdoc
will extract [field](https://docs.pydantic.dev/latest/concepts/fields/)
descriptions and treat them just like [documented
variables](#document-variables). For example, the following two Pydantic models
would have identical pdoc-rendered documentation:

```python
from pydantic import BaseModel, Field

class Foo(BaseModel):
a: int = Field(description="Docs for field a.")

class OtherFoo(BaseModel):
a: int
"""Docs for field a."""

```

## ...render math formulas?

Expand Down
97 changes: 97 additions & 0 deletions pdoc/_pydantic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Work with Pydantic models."""

from importlib import import_module
from types import ModuleType
from typing import TYPE_CHECKING
from typing import Any
from typing import Final
from typing import Optional
from typing import TypeVar
from typing import cast

from pdoc.docstrings import AnyException

if TYPE_CHECKING:
import pydantic
else:
pydantic: Optional[ModuleType]
try:
pydantic = import_module("pydantic")
except AnyException:
pydantic = None


_IGNORED_FIELDS: Final[list[str]] = ["__fields__"]
"""Fields to ignore when generating docs, e.g. those that emit deprecation warnings."""

T = TypeVar("T")


def is_pydantic_model(obj: Any) -> bool:
"""Returns whether an object is a Pydantic model.

Raises:
ModuleNotFoundError: when function is called but Pydantic is not on the PYTHONPATH.

"""
if pydantic is None:
raise ModuleNotFoundError(
"_pydantic.is_pydantic_model() needs Pydantic installed"
)

return pydantic.BaseModel in obj.__bases__


def default_value(parent: Any, name: str, obj: T) -> T:
"""Determine the default value of obj.

If pydantic is not installed or the parent type is not a Pydantic model,
simply returns obj.

"""
if (
pydantic is not None
and isinstance(parent, type)
and issubclass(parent, pydantic.BaseModel)
):
_parent = cast(pydantic.BaseModel, parent)
pydantic_fields = _parent.__pydantic_fields__
return pydantic_fields[name].default if name in pydantic_fields else obj

return obj


def get_field_docstring(parent: Any, field_name: str) -> Optional[str]:
if (
pydantic is not None
and isinstance(parent, type)
and issubclass(parent, pydantic.BaseModel)
):
pydantic_fields = parent.__pydantic_fields__
return (
pydantic_fields[field_name].description
if field_name in pydantic_fields
else None
)

return None


def skip_field(
*,
parent_kind: str,
parent_obj: Any,
name: str,
taken_from: tuple[str, str],
) -> bool:
"""For Pydantic models, filter out all methods on the BaseModel
class, as they are almost never relevant to the consumers of the
inheriting model itself.
"""

return (
pydantic is not None
and parent_kind == "class"
and is_pydantic_model(parent_obj)
and (name in _IGNORED_FIELDS or taken_from[0].startswith("pydantic"))
)
37 changes: 32 additions & 5 deletions pdoc/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from typing import get_origin
import warnings

from pdoc import _pydantic
from pdoc import doc_ast
from pdoc import doc_pyi
from pdoc import extract
Expand Down Expand Up @@ -257,6 +258,15 @@ def members(self) -> dict[str, Doc]:
for name, obj in self._member_objects.items():
qualname = f"{self.qualname}.{name}".lstrip(".")
taken_from = self._taken_from(name, obj)

if _pydantic.skip_field(
parent_kind=self.kind,
parent_obj=self.obj,
name=name,
taken_from=taken_from,
):
continue

doc: Doc[Any]

is_classmethod = isinstance(obj, classmethod)
Expand Down Expand Up @@ -314,18 +324,35 @@ def members(self) -> dict[str, Doc]:
taken_from=taken_from,
)
else:
default_value = obj

default_value = _pydantic.default_value(self.obj, name, obj)

doc = Variable(
self.modulename,
qualname,
docstring="",
annotation=self._var_annotations.get(name, empty),
default_value=obj,
default_value=default_value,
taken_from=taken_from,
)
if self._var_docstrings.get(name):
doc.docstring = self._var_docstrings[name]
if self._func_docstrings.get(name) and not doc.docstring:
doc.docstring = self._func_docstrings[name]

_docstring: str | None = None
if (
_pydantic.pydantic is not None
and self.kind == "class"
and _pydantic.is_pydantic_model(self.obj)
):
_docstring = _pydantic.get_field_docstring(self.obj, name)
Comment on lines +341 to +346
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidate conditional logic into _pydantic like w/ default_value().


if _docstring is None:
if self._var_docstrings.get(name):
doc.docstring = self._var_docstrings[name]
if self._func_docstrings.get(name) and not doc.docstring:
doc.docstring = self._func_docstrings[name]
else:
doc.docstring = _docstring

members[doc.name] = doc

if isinstance(self, Module):
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ dev-dependencies = [
"pytest-timeout>=2.3.1",
"hypothesis>=6.113.0",
"pdoc-pyo3-sample-library>=1.0.11",
"pydantic>=2.12.0",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions test/test_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def outfile(self, format: str) -> Path:
"include_undocumented": False,
},
),
Snapshot("with_pydantic"),
]


Expand Down
160 changes: 160 additions & 0 deletions test/testdata/with_pydantic.html

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions test/testdata/with_pydantic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
A small example with Pydantic entities.
"""

import pydantic


class Foo(pydantic.BaseModel):
"""Foo class documentation."""

model_config = pydantic.ConfigDict(
use_attribute_docstrings=True,
)

a: int = pydantic.Field(default=1, description="Docstring for a")

b: int = 2
"""Docstring for b."""
7 changes: 7 additions & 0 deletions test/testdata/with_pydantic.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<module with_pydantic # A small example with…
<class with_pydantic.Foo # Foo class documentat…
<var model_config = {'use_attribute_docstrings': True} # Configuration for th…>
<var a: int = 1 # Docstring for a>
<var b: int = 2 # Docstring for b.>
>
>
Loading
Loading