From 714fedae452dbed5e95632a68d2affb9286395ca Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Tue, 3 Sep 2024 12:17:07 +0100 Subject: [PATCH] Support pydantic dataclasses as well as BaseModel --- docs/source/users/examples.rst | 27 +++++++++++++ .../directives/autodocumenters.py | 6 ++- sphinxcontrib/autodoc_pydantic/inspection.py | 40 ++++++++++++++----- .../test-base/target/example_dataclasses.py | 11 +++++ tests/roots/test-base/target/examples.py | 7 ++++ tests/test_autodoc_examples.py | 31 ++++++++++++++ 6 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 tests/roots/test-base/target/example_dataclasses.py diff --git a/docs/source/users/examples.rst b/docs/source/users/examples.rst index 589b903b..9562deba 100644 --- a/docs/source/users/examples.rst +++ b/docs/source/users/examples.rst @@ -300,3 +300,30 @@ and the .. tab:: python .. autocodeblock:: target.example_reused_validators + + +.. _example_dataclasses: + +-------------------- +Pydantic Dataclasses +-------------------- + +Pydantic dataclasses are also supported + +.. tabs:: + + .. tab:: *rendered output* + + .. automodule:: target.example_dataclasses + :members: + + .. tab:: reST + + .. code-block:: + + .. automodule:: target.example_dataclasses + :members: + + .. tab:: python + + .. autocodeblock:: target.example_dataclasses diff --git a/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py b/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py index ce9445f1..bbd1e375 100644 --- a/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py +++ b/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py @@ -705,7 +705,7 @@ def can_document_member( field_name=membername, ) - return is_valid and is_field and isattr + return is_valid and is_field @property def pydantic_field_name(self) -> str: @@ -716,6 +716,10 @@ def pydantic_field_name(self) -> str: return self.objpath[-1] + def should_suppress_value_header(self) -> bool: + """We handle value ourself so suppress the superclass adding it.""" + return True + def add_directive_header(self, sig: str) -> None: """Delegate header options.""" super().add_directive_header(sig) diff --git a/sphinxcontrib/autodoc_pydantic/inspection.py b/sphinxcontrib/autodoc_pydantic/inspection.py index 00dd3750..edec669c 100644 --- a/sphinxcontrib/autodoc_pydantic/inspection.py +++ b/sphinxcontrib/autodoc_pydantic/inspection.py @@ -18,7 +18,16 @@ except ImportError: from typing_extensions import TypeGuard -from pydantic import BaseModel, ConfigDict, PydanticInvalidForJsonSchema, create_model +import dataclasses + +from pydantic import ( + BaseModel, + ConfigDict, + PydanticInvalidForJsonSchema, + TypeAdapter, + create_model, +) +from pydantic.dataclasses import is_pydantic_dataclass from pydantic_settings import BaseSettings ASTERISK_FIELD_NAME = 'all fields' @@ -105,7 +114,12 @@ class FieldInspector(BaseInspectionComposite): def __init__(self, parent: ModelInspector) -> None: super().__init__(parent) # json schema can reliably be created only at model level - self.attribute = self.model.model_fields + if is_pydantic_dataclass(self.model): + self.attribute = { + field.name: field.default for field in dataclasses.fields(self.model) + } + else: + self.attribute = self.model.model_fields @property def names(self) -> list[str]: @@ -154,7 +168,7 @@ def _get_meta_items(meta_class: Any) -> dict[str, str]: # noqa: ANN401 def get_constraints(self, field_name: str) -> dict[str, Any]: """Get constraints for given `field_name`.""" - metadata = self.model.model_fields[field_name].metadata + metadata = self.attribute[field_name].metadata available = [meta for meta in metadata if meta is not None] return { @@ -289,8 +303,10 @@ def _get_values_per_type(self) -> dict[str, Any]: values. Hence, the default values are removed. """ - - cfg = self.model.model_config + if is_pydantic_dataclass(self.model): + cfg = self.model.__pydantic_config__ + else: + cfg = self.model.model_config if issubclass(self.model, BaseSettings): default = tuple(BaseSettings.model_config.items()) @@ -389,7 +405,10 @@ def sanitized(self) -> dict: try: with warnings.catch_warnings(): warnings.simplefilter('ignore') - schema = self.model.model_json_schema() + if is_pydantic_dataclass(self.model): + schema = TypeAdapter(self.model).json_schema() + else: + schema = self.model.model_json_schema() except (TypeError, ValueError, PydanticInvalidForJsonSchema): new_model = self.create_sanitized_model() @@ -419,7 +438,7 @@ def is_pydantic_model(obj: Any) -> TypeGuard[type[BaseModel]]: # noqa: ANN401 """Determine if object is a valid pydantic model.""" try: - return issubclass(obj, BaseModel) + return issubclass(obj, BaseModel) or is_pydantic_dataclass(obj) except TypeError: return False @@ -429,8 +448,11 @@ def is_pydantic_field(cls, parent: Any, field_name: str) -> bool: # noqa: ANN40 if not cls.is_pydantic_model(parent): return False - - return field_name in parent.model_fields + if is_pydantic_dataclass(parent): + fields = {field.name for field in dataclasses.fields(parent)} + else: + fields = parent.model_fields + return field_name in fields @classmethod def is_validator_by_name(cls, name: str, obj: Any) -> bool: # noqa: ANN401 diff --git a/tests/roots/test-base/target/example_dataclasses.py b/tests/roots/test-base/target/example_dataclasses.py new file mode 100644 index 00000000..552d1224 --- /dev/null +++ b/tests/roots/test-base/target/example_dataclasses.py @@ -0,0 +1,11 @@ +from pydantic import Field +from pydantic.dataclasses import dataclass + + +@dataclass +class ExampleDataclass: + """An example Pydantic dataclass.""" + + name: str = Field(description="The name of the object") + age: int = Field(5, description="The age of the object") + diff --git a/tests/roots/test-base/target/examples.py b/tests/roots/test-base/target/examples.py index e7e4d90a..d938843d 100644 --- a/tests/roots/test-base/target/examples.py +++ b/tests/roots/test-base/target/examples.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, field_validator, Field, ConfigDict +from pydantic.dataclasses import dataclass class PlainModel(BaseModel): @@ -12,6 +13,12 @@ class ModelWithField(BaseModel): """Doc field""" +@dataclass +class DataclassWithField: + """Dataclass With Field.""" + field: int = Field(5, description="The Field") + + class ModelWithFieldValidator(BaseModel): """Model With Field Validator.""" diff --git a/tests/test_autodoc_examples.py b/tests/test_autodoc_examples.py index e04c0518..2a900b5d 100644 --- a/tests/test_autodoc_examples.py +++ b/tests/test_autodoc_examples.py @@ -50,6 +50,37 @@ def test_model_with_field(autodocument): ] +def test_dataclass_with_field(autodocument): + options_app = dict(autodoc_pydantic_model_show_json=False) + options_doc = dict(members=None) + actual = autodocument( + documenter='pydantic_model', + object_path='target.examples.DataclassWithField', + options_doc=options_doc, + options_app=options_app, + ) + + assert actual == [ + '', + '.. py:pydantic_model:: DataclassWithField', + ' :module: target.examples', + '', + ' Dataclass With Field.', + '', + ' :Fields:', + ' - :py:obj:`field (int) `', + '', + '', + ' .. py:pydantic_field:: DataclassWithField.field', + ' :module: target.examples', + ' :type: int', + ' :value: 5', + '', + ' The Field', + '', + ] + + def test_model_with_field_validator(autodocument): options_app = dict( autodoc_pydantic_model_show_json=False,