From b2f945bab3a7cc517a8b715fe087c1d374420c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Macia=C5=9B?= Date: Sun, 11 Feb 2024 17:31:18 +0100 Subject: [PATCH] Add legacy pydantic v1 support (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added back pydantic v1 support. * Format fixes * Added pydantic v1 tests back * Fix lint * Update changelog --------- Co-authored-by: Mateusz Maciaƛ Co-authored-by: Jay Qi --- HISTORY.md | 6 +- README.md | 3 +- erdantic/__init__.py | 1 + erdantic/pydantic1.py | 105 ++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_pydantic1.py | 128 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 erdantic/pydantic1.py create mode 100644 tests/test_pydantic1.py diff --git a/HISTORY.md b/HISTORY.md index 5c2c0021..5a955672 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # erdantic Changelog +## v0.7.0 (2024-02-11) + +- Added support for Pydantic V1 legacy models. These are models created from the `pydantic.v1` namespace when Pydantic V2 is installed. ([PR #94](https://github.com/drivendataorg/erdantic/pull/94) from [@ursereg](https://github.com/ursereg)) + ## v0.6.0 (2023-07-09) - Added support for Pydantic V2. @@ -9,7 +13,7 @@ ## v0.5.1 (2023-07-04) -- Changed pydantic dependency to be `< 2`. This will be the final version of erdantic that supports pydantic v1. +- Changed Pydantic dependency to be `< 2`. This will be the final version of erdantic that supports Pydantic V1. - Changed to pyproject.toml-based build. ## v0.5.0 (2022-07-29) diff --git a/README.md b/README.md index fd932468..d8d30fd1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ **erdantic** is a simple tool for drawing [entity relationship diagrams (ERDs)](https://en.wikipedia.org/wiki/Data_modeling#Entity%E2%80%93relationship_diagrams) for Python data model classes. Diagrams are rendered using the venerable [Graphviz](https://graphviz.org/) library. Supported data modeling frameworks are: -- [Pydantic](https://pydantic-docs.helpmanual.io/) +- [Pydantic V2](https://docs.pydantic.dev/latest/) +- [Pydantic V1 legacy](https://docs.pydantic.dev/latest/migration/#continue-using-pydantic-v1-features) - [dataclasses](https://docs.python.org/3/library/dataclasses.html) from the Python standard library Features include a convenient CLI, automatic native rendering in Jupyter notebooks, and easy extensibility to other data modeling frameworks. Docstrings are even accessible as tooltips for SVG outputs. Great for adding a simple and clean data model reference to your documentation. diff --git a/erdantic/__init__.py b/erdantic/__init__.py index 0994c924..f24793ab 100644 --- a/erdantic/__init__.py +++ b/erdantic/__init__.py @@ -1,6 +1,7 @@ import erdantic.dataclasses # noqa: F401 from erdantic.erd import EntityRelationshipDiagram, create, draw, to_dot import erdantic.pydantic # noqa: F401 +import erdantic.pydantic1 # noqa: F401 from erdantic.version import __version__ __version__ diff --git a/erdantic/pydantic1.py b/erdantic/pydantic1.py new file mode 100644 index 00000000..6445749e --- /dev/null +++ b/erdantic/pydantic1.py @@ -0,0 +1,105 @@ +from typing import Any, List, Optional, Type, Union + +import pydantic.v1 +import pydantic.v1.fields + +from erdantic.base import Field, Model, register_model_adapter +from erdantic.exceptions import InvalidFieldError, InvalidModelError +from erdantic.typing import GenericAlias, repr_type_with_mro + + +class Pydantic1Field(Field[pydantic.v1.fields.ModelField]): + """Concrete field adapter class for Pydantic fields. + + Attributes: + field (pydantic.fields.ModelField): The Pydantic field object that is associated with this + adapter instance. + """ + + def __init__(self, field: pydantic.v1.fields.ModelField): + if not isinstance(field, pydantic.v1.fields.ModelField): + raise InvalidFieldError( + f"field must be of type pydantic.fields.ModelField. Got: {type(field)}" + ) + super().__init__(field=field) + + @property + def name(self) -> str: + return self.field.name + + @property + def type_obj(self) -> Union[type, GenericAlias]: + tp = self.field.outer_type_ + if self.field.allow_none: + return Optional[tp] + return tp + + def is_many(self) -> bool: + return self.field.shape > 1 + + def is_nullable(self) -> bool: + return self.field.allow_none + + +@register_model_adapter("pydantic1") +class PydanticModel(Model[Type[pydantic.v1.BaseModel]]): + """Concrete model adapter class for a Pydantic + [`BaseModel`](https://pydantic-docs.helpmanual.io/usage/models/). + + Attributes: + model (Type[pydantic.BaseModel]): The Pydantic model class that is associated with this + adapter instance. + forward_ref_help (Optional[str]): Instructions for how to resolve an unevaluated forward + reference in a field's type declaration. + """ + + forward_ref_help = ( + "Call 'update_forward_refs' after model is created to resolve. " + "See: https://pydantic-docs.helpmanual.io/usage/postponed_annotations/" + ) + + def __init__(self, model: Type[pydantic.v1.BaseModel]): + if not self.is_model_type(model): + raise InvalidModelError( + "Argument model must be a subclass of pydantic.v1.BaseModel. " + f"Got {repr_type_with_mro(model)}" + ) + super().__init__(model=model) + + @staticmethod + def is_model_type(obj: Any) -> bool: + return isinstance(obj, type) and issubclass(obj, pydantic.v1.BaseModel) + + @property + def fields(self) -> List[Field]: + return [Pydantic1Field(field=f) for f in self.model.__fields__.values()] + + @property + def docstring(self) -> str: + out = super().docstring + field_descriptions = [ + getattr(field.field.field_info, "description", None) for field in self.fields + ] + if any(descr is not None for descr in field_descriptions): + # Sometimes Pydantic models have field documentation as descriptions as metadata on the + # field instead of in the docstring. If detected, construct docstring and add. + out += "\nAttributes:\n" + field_defaults = [field.field.field_info.default for field in self.fields] + for field, descr, default in zip(self.fields, field_descriptions, field_defaults): + if descr is not None: + line = f"{field.name} ({field.type_name}): {descr}" + if ( + not isinstance(default, pydantic.v1.fields.UndefinedType) + and default is not ... + ): + if not line.strip().endswith("."): + line = line.rstrip() + ". " + else: + line = line.rstrip() + " " + if isinstance(default, str): + line += f"Default is '{default}'." + else: + line += f"Default is {default}." + out += " " + line.strip() + "\n" + + return out diff --git a/pyproject.toml b/pyproject.toml index df2b1c3f..fef69a2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "erdantic" -version = "0.6.0" +version = "0.7.0" description = "Entity relationship diagrams for Python data model classes like Pydantic." readme = "README.md" authors = [{ name = "DrivenData", email = "info@drivendata.org" }] diff --git a/tests/test_pydantic1.py b/tests/test_pydantic1.py new file mode 100644 index 00000000..f4befe15 --- /dev/null +++ b/tests/test_pydantic1.py @@ -0,0 +1,128 @@ +import textwrap +from typing import Any, Dict, List, Optional, Tuple + +from pydantic.v1 import BaseModel, Field +import pytest + +import erdantic as erd +from erdantic.exceptions import UnevaluatedForwardRefError +from erdantic.pydantic1 import PydanticModel + + +def test_model_graph_search_nested_args(): + class Inner0(BaseModel): + id: int + + class Inner1(BaseModel): + id: int + + class Outer(BaseModel): + inner: Dict[str, Tuple[Inner0, Inner1]] + + diagram = erd.create(Outer) + assert {m.model for m in diagram.models} == {Outer, Inner0, Inner1} + assert {(e.source.model, e.target.model) for e in diagram.edges} == { + (Outer, Inner0), + (Outer, Inner1), + } + + +def test_unevaluated_forward_ref(): + class Pydantic1Item(BaseModel): + name: str + + class Pydantic1Container(BaseModel): + items: List["Pydantic1Item"] + + # Unevaluated forward ref should error + with pytest.raises(UnevaluatedForwardRefError, match="update_forward_refs"): + _ = erd.create(Pydantic1Container) + + # Evaluate forward ref + Pydantic1Container.update_forward_refs(**locals()) + + # Test that model can be used + _ = Pydantic1Container(items=[Pydantic1Item(name="thingie")]) + + diagram = erd.create(Pydantic1Container) + assert {m.model for m in diagram.models} == {Pydantic1Container, Pydantic1Item} + assert {(e.source.model, e.target.model) for e in diagram.edges} == { + (Pydantic1Container, Pydantic1Item) + } + + +def test_field_names(): + class MyClass(BaseModel): + a: str + b: Optional[str] + c: List[str] + d: Tuple[str, ...] + e: Tuple[str, int] + f: Dict[str, List[int]] + g: Optional[List[str]] + h: Dict[str, Optional[int]] + + model = PydanticModel(MyClass) + assert [f.type_name for f in model.fields] == [ + "str", + "Optional[str]", + "List[str]", + "Tuple[str, ...]", + "Tuple[str, int]", + "Dict[str, List[int]]", + "Optional[List[str]]", + "Dict[str, Optional[int]]", + ] + + +def test_docstring_field_descriptions(): + # Does not use pydantic.Field with descriptions. Shouldn't add anything. + class MyClassWithoutDescriptions(BaseModel): + """This is the docstring for my class without descriptions.""" + + hint_only: str + no_descr_has_default: Any = Field(10) + + model = PydanticModel(MyClassWithoutDescriptions) + print("===Actual w/o Descriptions===") + print(model.docstring) + print("============") + + expected = textwrap.dedent( + """\ + tests.test_pydantic1.test_docstring_field_descriptions..MyClassWithoutDescriptions + + This is the docstring for my class without descriptions. + """ + ) + assert model.docstring == expected + + # Does use pydantic.Field with descriptions. Should add attributes section + + class MyClassWithDescriptions(BaseModel): + """This is the docstring for my class with descriptions.""" + + hint_only: str + has_descr_no_default: List[int] = Field(description="An array of numbers.") + has_descr_ellipsis_default: List[int] = Field(..., description="Another array of numbers.") + no_descr_has_default: Any = Field(10) + has_descr_has_default: Optional[str] = Field(None, description="An optional string.") + + model = PydanticModel(MyClassWithDescriptions) + print("===Actual w/ Descriptions===") + print(model.docstring) + print("============") + + expected = textwrap.dedent( + """\ + tests.test_pydantic1.test_docstring_field_descriptions..MyClassWithDescriptions + + This is the docstring for my class with descriptions. + + Attributes: + has_descr_no_default (List[int]): An array of numbers. + has_descr_ellipsis_default (List[int]): Another array of numbers. + has_descr_has_default (Optional[str]): An optional string. Default is None. + """ + ) + assert model.docstring == expected