Skip to content

Commit

Permalink
feat: Remove duplicated docs strings when use_attribute_docstrings
Browse files Browse the repository at this point in the history
…is used in `pydantic >= 2.7` (#276)
  • Loading branch information
mansenfranzen authored Apr 25, 2024
1 parent 65ed9cc commit 0ddb12f
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 30 deletions.
21 changes: 16 additions & 5 deletions docs/source/users/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -460,12 +460,23 @@ Fields
:enable: members
:values: docstring, description, both*

Define what content is displayed in the main field docstring. The following
values are possible:
This configuration determines what information appears in the documentation for each field in a model. You can select from the following options:

- **docstring** shows the exact docstring of the python attribute.
- **description** displays the information provided via the pydantic field's description.
- **both** will output the attribute's docstring together with the pydantic field's description.
**Available values:**

- ``docstring``: Displays the original docstring from the Python attribute.
- ``description``: Shows the description provided in the Pydantic model field.
- ``both``: Includes both the attribute's docstring and the Pydantic field's description in the documentation.

.. hint::

As of Pydantic version 2.7, you can enable the model configuration setting
`use_attribute_docstrings <https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.use_attribute_docstrings>`_.
When enabled, Pydantic will use the attribute's docstring as the field's description
by default, unless a specific description is provided. To avoid
redundancy in documentation, especially when using the ``both`` option,
**autodoc_pydantic** automatically checks for and removes any duplicate docstrings,
ensuring your documentation remains clear and concise.


.. config_description:: autopydantic_model
Expand Down
68 changes: 50 additions & 18 deletions sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
)
from sphinxcontrib.autodoc_pydantic.directives.templates import to_collapsable
from sphinxcontrib.autodoc_pydantic.directives.utility import (
NONE,
intercept_type_annotations_py_gt_39,
)
from sphinxcontrib.autodoc_pydantic.inspection import (
Expand Down Expand Up @@ -784,25 +783,41 @@ def add_alias(self) -> None:
sourcename = self.get_sourcename()
self.add_line(f' :alias: {field.alias}', sourcename)

@property
def needs_doc_string(self) -> bool:
"""Indicate if docstring from attribute should be added to field."""

doc_policy = self.pydantic.options.get_value('field-doc-policy')
return doc_policy != OptionsFieldDocPolicy.DESCRIPTION

@property
def needs_description(self) -> bool:
"""Indicate if pydantic field description should be added to field."""

doc_policy = self.pydantic.options.get_value('field-doc-policy')
is_enabled = doc_policy in (
OptionsFieldDocPolicy.BOTH,
OptionsFieldDocPolicy.DESCRIPTION,
)

description = self._get_field_description()
has_description = bool(description)

identical_doc = description == self._get_pydantic_sanitized_doc_string()
is_duplicated = identical_doc and self.needs_doc_string

return is_enabled and has_description and not is_duplicated

def add_content(
self,
more_content: StringList | None,
**kwargs, # noqa: ANN003
) -> None:
"""Delegate additional content creation."""

doc_policy = self.pydantic.options.get_value('field-doc-policy')
if doc_policy in (
OptionsFieldDocPolicy.DOCSTRING,
OptionsFieldDocPolicy.BOTH,
None,
NONE,
):
if self.needs_doc_string:
super().add_content(more_content, **kwargs)
if doc_policy in (
OptionsFieldDocPolicy.BOTH,
OptionsFieldDocPolicy.DESCRIPTION,
):
if self.needs_description:
self.add_description()

if self.pydantic.options.is_true('field-show-constraints'):
Expand All @@ -826,22 +841,39 @@ def add_constraints(self) -> None:

self.add_line('', source_name)

def add_description(self) -> None:
"""Adds description from schema if present."""
def _get_field_description(self) -> str:
"""Get field description from schema if present."""

field_name = self.pydantic_field_name
func = self.pydantic.inspect.fields.get_property_from_field_info
description = func(field_name, 'description')
return func(field_name, 'description')

if not description:
return
def _get_pydantic_sanitized_doc_string(self) -> str:
"""Helper to get sanitized docstring for pydantic field that
uses same formatting as pydantic's method to extract the doc
string for automated field description provisioning.
"""

docstrings = self.get_doc()
if not docstrings:
return ''

docstring = docstrings[0] # first element is always the docstring
without_last = docstring[:-1] # last element is always empty
substitute_linebreaks = ['\n\n' if x == '' else x for x in without_last]
return ''.join(substitute_linebreaks)

def add_description(self) -> None:
"""Adds description from schema if present."""

description = self._get_field_description()

tabsize = self.directive.state.document.settings.tab_width
lines = prepare_docstring(description, tabsize=tabsize)
source_name = self.get_sourcename()
for line in lines:
self.add_line(line, source_name)
self.add_line('', source_name)

def add_validators(self) -> None:
"""Add section with all validators that process this field."""
Expand Down
16 changes: 12 additions & 4 deletions tests/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
"""

import importlib
import sys
from typing import Tuple, List
import re
import sys
from typing import List, Tuple

import pydantic
import sphinx
from sphinx.addnodes import desc_sig_punctuation, desc_annotation, pending_xref
from packaging.version import Version
from sphinx.addnodes import desc_annotation, desc_sig_punctuation, pending_xref


def desc_annotation_default_value(value: str):
Expand Down Expand Up @@ -177,8 +178,15 @@ def pre_python310():
return sys.version_info < (3, 10)


def pydantic_ge_27():
"""Determine if pydantic version is greater equals 2.7."""

return Version(pydantic.__version__) >= Version('2.7')


TYPING_MODULE_PREFIX_V1 = typing_module_prefix_v1()
TYPING_MODULE_PREFIX_V2 = typing_module_prefix_v2()
TYPEHINTS_PREFIX = typehints_prefix()
OPTIONAL_INT = TYPING_MODULE_PREFIX_V1 + get_optional_type_expected('Optional[int]')
PYTHON_LT_310 = pre_python310()
PYDANTIC_GE_27 = pydantic_ge_27()
26 changes: 26 additions & 0 deletions tests/roots/test-base/target/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,32 @@ class FieldDocPolicy(BaseModel):
"""Field."""


class FieldDocPolicyUseAttributeDocstrings(BaseModel):
"""FieldDocPolicy."""

field: int = Field(1)
"""Field Description is fetched from doc string.
Contains multiline doc string."""

model_config = ConfigDict(
use_attribute_docstrings=True,
)


class FieldDocPolicyUseAttributeDocstringsDocString(BaseModel):
"""FieldDocPolicy."""

field: int = Field(1, description='Custom Desc.')
"""Field Description is fetched from doc string.
Contains multiline doc string."""

model_config = ConfigDict(
use_attribute_docstrings=True,
)


class FieldShowConstraints(BaseModel):
"""FieldShowConstraints."""

Expand Down
Loading

0 comments on commit 0ddb12f

Please sign in to comment.