-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add legacy pydantic v1 support (#94)
* 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
1 parent
777eb3c
commit b2f945b
Showing
6 changed files
with
242 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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]" }] | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |