diff --git a/docs/source/users/configuration.rst b/docs/source/users/configuration.rst index c6fc2e29..7d36013d 100644 --- a/docs/source/users/configuration.rst +++ b/docs/source/users/configuration.rst @@ -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 `_. + 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 diff --git a/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py b/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py index 34e47815..ce9445f1 100644 --- a/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py +++ b/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py @@ -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 ( @@ -784,6 +783,31 @@ 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, @@ -791,18 +815,9 @@ def add_content( ) -> 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'): @@ -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.""" diff --git a/tests/compatibility.py b/tests/compatibility.py index fc16998f..f96fb45c 100644 --- a/tests/compatibility.py +++ b/tests/compatibility.py @@ -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): @@ -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() diff --git a/tests/roots/test-base/target/configuration.py b/tests/roots/test-base/target/configuration.py index 05754fb0..d2f2deb4 100644 --- a/tests/roots/test-base/target/configuration.py +++ b/tests/roots/test-base/target/configuration.py @@ -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.""" diff --git a/tests/test_configuration_fields.py b/tests/test_configuration_fields.py index 6901cb6c..d009ece2 100644 --- a/tests/test_configuration_fields.py +++ b/tests/test_configuration_fields.py @@ -18,6 +18,7 @@ from .compatibility import ( OPTIONAL_INT, + PYDANTIC_GE_27, TYPEHINTS_PREFIX, TYPING_MODULE_PREFIX_V1, TYPING_MODULE_PREFIX_V2, @@ -143,7 +144,6 @@ def test_autodoc_pydantic_field_doc_policy_description(autodocument): '', ' Custom Desc.', '', - '', ] # explict global @@ -178,7 +178,6 @@ def test_autodoc_pydantic_field_doc_policy_both(autodocument): '', ' Custom Desc.', '', - '', ] # explict global @@ -200,6 +199,174 @@ def test_autodoc_pydantic_field_doc_policy_both(autodocument): assert result == actual +def test_autodoc_pydantic_field_doc_policy_attribute_doc_string(autodocument): + kwargs = dict( + object_path='target.configuration.FieldDocPolicyUseAttributeDocstrings.field', + **KWARGS, + ) + + result = [ + '', + '.. py:pydantic_field:: FieldDocPolicyUseAttributeDocstrings.field', + ' :module: target.configuration', + ' :type: int', + '', + ' Field Description is fetched from doc string.', + '', + ' Contains multiline doc string.', + '', + ] + + result_description = result if PYDANTIC_GE_27 else result[:-4] + + # explict global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'docstring'}, **kwargs + ) + assert result == actual + + # explict global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'description'}, **kwargs + ) + assert result_description == actual + + # explict global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'both'}, **kwargs + ) + assert result == actual + + # explicit local + actual = autodocument(options_doc={'field-doc-policy': 'docstring'}, **kwargs) + assert result == actual + + # explicit local + actual = autodocument(options_doc={'field-doc-policy': 'description'}, **kwargs) + assert result_description == actual + + # explicit local + actual = autodocument(options_doc={'field-doc-policy': 'both'}, **kwargs) + assert result == actual + + +def test_autodoc_pydantic_field_doc_policy_use_attribute_docstring_docstring( + autodocument, +): + kwargs = dict( + object_path='target.configuration.FieldDocPolicyUseAttributeDocstringsDocString.field', + **KWARGS, + ) + + result = [ + '', + '.. py:pydantic_field:: FieldDocPolicyUseAttributeDocstringsDocString.field', + ' :module: target.configuration', + ' :type: int', + '', + ' Field Description is fetched from doc string.', + '', + ' Contains multiline doc string.', + '', + ] + + # explict global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'docstring'}, **kwargs + ) + assert result == actual + + # explicit local + actual = autodocument(options_doc={'field-doc-policy': 'docstring'}, **kwargs) + assert result == actual + + # explicit local overwrite global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'both'}, + options_doc={'field-doc-policy': 'docstring'}, + **kwargs, + ) + assert result == actual + + +def test_autodoc_pydantic_field_doc_policy_use_attribute_docstring_both( + autodocument, +): + kwargs = dict( + object_path='target.configuration.FieldDocPolicyUseAttributeDocstringsDocString.field', + **KWARGS, + ) + + result = [ + '', + '.. py:pydantic_field:: FieldDocPolicyUseAttributeDocstringsDocString.field', + ' :module: target.configuration', + ' :type: int', + '', + ' Field Description is fetched from doc string.', + '', + ' Contains multiline doc string.', + '', + ' Custom Desc.', + '', + ] + + # explict global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'both'}, **kwargs + ) + assert result == actual + + # explicit local + actual = autodocument(options_doc={'field-doc-policy': 'both'}, **kwargs) + assert result == actual + + # explicit local overwrite global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'description'}, + options_doc={'field-doc-policy': 'both'}, + **kwargs, + ) + assert result == actual + + +def test_autodoc_pydantic_field_doc_policy_use_attribute_docstring_description( + autodocument, +): + kwargs = dict( + object_path='target.configuration.FieldDocPolicyUseAttributeDocstringsDocString.field', + **KWARGS, + ) + + result = [ + '', + '.. py:pydantic_field:: FieldDocPolicyUseAttributeDocstringsDocString.field', + ' :module: target.configuration', + ' :type: int', + '', + ' Custom Desc.', + '', + ] + + # explict global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'description'}, **kwargs + ) + assert result == actual + + # explicit local + actual = autodocument(options_doc={'field-doc-policy': 'description'}, **kwargs) + assert result == actual + + # explicit local overwrite global + actual = autodocument( + options_app={'autodoc_pydantic_field_doc_policy': 'both'}, + options_doc={'field-doc-policy': 'description'}, + **kwargs, + ) + assert result == actual + + def test_autodoc_pydantic_field_show_constraints_true(autodocument): """Ensure that constraints are properly show via the `Field` type annotation. diff --git a/tests/test_edgecases.py b/tests/test_edgecases.py index 51f68b02..df3a962d 100644 --- a/tests/test_edgecases.py +++ b/tests/test_edgecases.py @@ -640,7 +640,6 @@ def test_field_description_correct_rst_rendering(autodocument): '', ' :fieldlist: item', '', - '', ] actual = autodocument(