Skip to content

Commit

Permalink
Add legacy pydantic v1 support (#94)
Browse files Browse the repository at this point in the history
* Added back pydantic v1 support.

* Format fixes

* Added pydantic v1 tests back

* Fix lint

* Update changelog

---------

Co-authored-by: Mateusz Maciaś <[email protected]>
Co-authored-by: Jay Qi <[email protected]>
  • Loading branch information
3 people authored Feb 11, 2024
1 parent 777eb3c commit b2f945b
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 3 deletions.
6 changes: 5 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions erdantic/__init__.py
Original file line number Diff line number Diff line change
@@ -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__
Expand Down
105 changes: 105 additions & 0 deletions erdantic/pydantic1.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]" }]
Expand Down
128 changes: 128 additions & 0 deletions tests/test_pydantic1.py
Original file line number Diff line number Diff line change
@@ -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.<locals>.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.<locals>.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

0 comments on commit b2f945b

Please sign in to comment.