Skip to content

Commit 1c0b80d

Browse files
committed
feat(client): add forwards-compatible pydantic methods (#121)
If you're still using Pydantic v1 then we've added aliases for these methods introduced in Pydantic v2: - model_dump - model_dump_json
1 parent 2a5f9b6 commit 1c0b80d

File tree

4 files changed

+182
-3
lines changed

4 files changed

+182
-3
lines changed

noxfile.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ def test_pydantic_v1(session: nox.Session) -> None:
99
# https://github.com/cjolowicz/nox-poetry/issues/1116
1010
session._session.run("python", "-m", "pip", "install", "pydantic<2", external=True) # type: ignore
1111

12-
session.run("pytest", "--showlocals", "--ignore=tests/functional")
12+
session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs)

src/finch/_models.py

+101-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import inspect
44
from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, cast
55
from datetime import date, datetime
6-
from typing_extensions import ClassVar, Protocol, final, runtime_checkable
6+
from typing_extensions import Literal, ClassVar, Protocol, final, runtime_checkable
77

88
import pydantic
99
import pydantic.generics
@@ -12,6 +12,7 @@
1212

1313
from ._types import (
1414
Body,
15+
IncEx,
1516
Query,
1617
ModelT,
1718
Headers,
@@ -124,6 +125,105 @@ def construct(
124125
# although not in practice
125126
model_construct = construct
126127

128+
if not PYDANTIC_V2:
129+
# we define aliases for some of the new pydantic v2 methods so
130+
# that we can just document these methods without having to specify
131+
# a specifc pydantic version as some users may not know which
132+
# pydantic version they are currently using
133+
134+
def model_dump(
135+
self,
136+
*,
137+
mode: Literal["json", "python"] | str = "python",
138+
include: IncEx = None,
139+
exclude: IncEx = None,
140+
by_alias: bool = False,
141+
exclude_unset: bool = False,
142+
exclude_defaults: bool = False,
143+
exclude_none: bool = False,
144+
round_trip: bool = False,
145+
warnings: bool = True,
146+
) -> dict[str, Any]:
147+
"""Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump
148+
149+
Generate a dictionary representation of the model, optionally specifying which fields to include or exclude.
150+
151+
Args:
152+
mode: The mode in which `to_python` should run.
153+
If mode is 'json', the dictionary will only contain JSON serializable types.
154+
If mode is 'python', the dictionary may contain any Python objects.
155+
include: A list of fields to include in the output.
156+
exclude: A list of fields to exclude from the output.
157+
by_alias: Whether to use the field's alias in the dictionary key if defined.
158+
exclude_unset: Whether to exclude fields that are unset or None from the output.
159+
exclude_defaults: Whether to exclude fields that are set to their default value from the output.
160+
exclude_none: Whether to exclude fields that have a value of `None` from the output.
161+
round_trip: Whether to enable serialization and deserialization round-trip support.
162+
warnings: Whether to log warnings when invalid fields are encountered.
163+
164+
Returns:
165+
A dictionary representation of the model.
166+
"""
167+
if mode != "python":
168+
raise ValueError("mode is only supported in Pydantic v2")
169+
if round_trip != False:
170+
raise ValueError("round_trip is only supported in Pydantic v2")
171+
if warnings != True:
172+
raise ValueError("warnings is only supported in Pydantic v2")
173+
return super().dict( # pyright: ignore[reportDeprecated]
174+
include=include,
175+
exclude=exclude,
176+
by_alias=by_alias,
177+
exclude_unset=exclude_unset,
178+
exclude_defaults=exclude_defaults,
179+
exclude_none=exclude_none,
180+
)
181+
182+
def model_dump_json(
183+
self,
184+
*,
185+
indent: int | None = None,
186+
include: IncEx = None,
187+
exclude: IncEx = None,
188+
by_alias: bool = False,
189+
exclude_unset: bool = False,
190+
exclude_defaults: bool = False,
191+
exclude_none: bool = False,
192+
round_trip: bool = False,
193+
warnings: bool = True,
194+
) -> str:
195+
"""Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json
196+
197+
Generates a JSON representation of the model using Pydantic's `to_json` method.
198+
199+
Args:
200+
indent: Indentation to use in the JSON output. If None is passed, the output will be compact.
201+
include: Field(s) to include in the JSON output. Can take either a string or set of strings.
202+
exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings.
203+
by_alias: Whether to serialize using field aliases.
204+
exclude_unset: Whether to exclude fields that have not been explicitly set.
205+
exclude_defaults: Whether to exclude fields that have the default value.
206+
exclude_none: Whether to exclude fields that have a value of `None`.
207+
round_trip: Whether to use serialization/deserialization between JSON and class instance.
208+
warnings: Whether to show any warnings that occurred during serialization.
209+
210+
Returns:
211+
A JSON string representation of the model.
212+
"""
213+
if round_trip != False:
214+
raise ValueError("round_trip is only supported in Pydantic v2")
215+
if warnings != True:
216+
raise ValueError("warnings is only supported in Pydantic v2")
217+
return super().json( # type: ignore[reportDeprecated]
218+
indent=indent,
219+
include=include,
220+
exclude=exclude,
221+
by_alias=by_alias,
222+
exclude_unset=exclude_unset,
223+
exclude_defaults=exclude_defaults,
224+
exclude_none=exclude_none,
225+
)
226+
127227

128228
def _construct_field(value: object, field: FieldInfo, key: str) -> object:
129229
if value is None:

src/finch/_types.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
Optional,
1515
Sequence,
1616
)
17-
from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable
17+
from typing_extensions import Literal, Protocol, TypeAlias, TypedDict, runtime_checkable
1818

1919
import httpx
2020
import pydantic
@@ -157,3 +157,7 @@ def get(self, __key: str) -> str | None:
157157
)
158158

159159
StrBytesIntFloat = Union[str, bytes, int, float]
160+
161+
# Note: copied from Pydantic
162+
# https://github.com/pydantic/pydantic/blob/32ea570bf96e84234d2992e1ddf40ab8a565925a/pydantic/main.py#L49
163+
IncEx: TypeAlias = "set[int] | set[str] | dict[int, Any] | dict[str, Any] | None"

tests/test_models.py

+75
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import json
12
from typing import Any, Dict, List, Union, Optional, cast
23
from datetime import datetime, timezone
34
from typing_extensions import Literal
45

56
import pytest
7+
import pydantic
68
from pydantic import Field
79

810
from finch._compat import PYDANTIC_V2, parse_obj, model_dump, model_json
@@ -485,3 +487,76 @@ class Model(BaseModel):
485487

486488
m = Model.construct(resource_id="foo")
487489
assert "resource_id" in m.model_fields_set
490+
491+
492+
def test_forwards_compat_model_dump_method() -> None:
493+
class Model(BaseModel):
494+
foo: Optional[str] = Field(alias="FOO", default=None)
495+
496+
m = Model(FOO="hello")
497+
assert m.model_dump() == {"foo": "hello"}
498+
assert m.model_dump(include={"bar"}) == {}
499+
assert m.model_dump(exclude={"foo"}) == {}
500+
assert m.model_dump(by_alias=True) == {"FOO": "hello"}
501+
502+
m2 = Model()
503+
assert m2.model_dump() == {"foo": None}
504+
assert m2.model_dump(exclude_unset=True) == {}
505+
assert m2.model_dump(exclude_none=True) == {}
506+
assert m2.model_dump(exclude_defaults=True) == {}
507+
508+
m3 = Model(FOO=None)
509+
assert m3.model_dump() == {"foo": None}
510+
assert m3.model_dump(exclude_none=True) == {}
511+
512+
if not PYDANTIC_V2:
513+
with pytest.raises(ValueError, match="mode is only supported in Pydantic v2"):
514+
m.model_dump(mode="json")
515+
516+
with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"):
517+
m.model_dump(round_trip=True)
518+
519+
with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"):
520+
m.model_dump(warnings=False)
521+
522+
523+
def test_forwards_compat_model_dump_json_method() -> None:
524+
class Model(BaseModel):
525+
foo: Optional[str] = Field(alias="FOO", default=None)
526+
527+
m = Model(FOO="hello")
528+
assert json.loads(m.model_dump_json()) == {"foo": "hello"}
529+
assert json.loads(m.model_dump_json(include={"bar"})) == {}
530+
assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"}
531+
assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"}
532+
533+
assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}'
534+
535+
m2 = Model()
536+
assert json.loads(m2.model_dump_json()) == {"foo": None}
537+
assert json.loads(m2.model_dump_json(exclude_unset=True)) == {}
538+
assert json.loads(m2.model_dump_json(exclude_none=True)) == {}
539+
assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {}
540+
541+
m3 = Model(FOO=None)
542+
assert json.loads(m3.model_dump_json()) == {"foo": None}
543+
assert json.loads(m3.model_dump_json(exclude_none=True)) == {}
544+
545+
if not PYDANTIC_V2:
546+
with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"):
547+
m.model_dump_json(round_trip=True)
548+
549+
with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"):
550+
m.model_dump_json(warnings=False)
551+
552+
553+
def test_type_compat() -> None:
554+
# our model type can be assigned to Pydantic's model type
555+
556+
def takes_pydantic(model: pydantic.BaseModel) -> None:
557+
...
558+
559+
class OurModel(BaseModel):
560+
foo: Optional[str] = None
561+
562+
takes_pydantic(OurModel())

0 commit comments

Comments
 (0)