From 19a046199f74755de212596f4ed5c799af77f216 Mon Sep 17 00:00:00 2001 From: Stainless Bot Date: Mon, 9 Oct 2023 11:00:06 +0000 Subject: [PATCH] feat(client): add forwards-compatible pydantic methods If you're still using Pydantic v1 then we've added aliases for these methods introduced in Pydantic v2: - model_dump - model_dump_json --- noxfile.py | 2 +- src/finch/_models.py | 102 ++++++++++++++++++++++++++++++++++++++++++- src/finch/_types.py | 6 ++- tests/test_models.py | 75 +++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/noxfile.py b/noxfile.py index bac3c5c8..669f6af7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -9,4 +9,4 @@ def test_pydantic_v1(session: nox.Session) -> None: # https://github.com/cjolowicz/nox-poetry/issues/1116 session._session.run("python", "-m", "pip", "install", "pydantic<2", external=True) # type: ignore - session.run("pytest", "--showlocals", "--ignore=tests/functional") + session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs) diff --git a/src/finch/_models.py b/src/finch/_models.py index 7bbdca3b..5568ee97 100644 --- a/src/finch/_models.py +++ b/src/finch/_models.py @@ -3,7 +3,7 @@ import inspect from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, cast from datetime import date, datetime -from typing_extensions import ClassVar, Protocol, final, runtime_checkable +from typing_extensions import Literal, ClassVar, Protocol, final, runtime_checkable import pydantic import pydantic.generics @@ -12,6 +12,7 @@ from ._types import ( Body, + IncEx, Query, ModelT, Headers, @@ -124,6 +125,105 @@ def construct( # although not in practice model_construct = construct + if not PYDANTIC_V2: + # we define aliases for some of the new pydantic v2 methods so + # that we can just document these methods without having to specify + # a specifc pydantic version as some users may not know which + # pydantic version they are currently using + + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: IncEx = None, + exclude: IncEx = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool = True, + ) -> dict[str, Any]: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump + + Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. + + Args: + mode: The mode in which `to_python` should run. + If mode is 'json', the dictionary will only contain JSON serializable types. + If mode is 'python', the dictionary may contain any Python objects. + include: A list of fields to include in the output. + exclude: A list of fields to exclude from the output. + by_alias: Whether to use the field's alias in the dictionary key if defined. + exclude_unset: Whether to exclude fields that are unset or None from the output. + exclude_defaults: Whether to exclude fields that are set to their default value from the output. + exclude_none: Whether to exclude fields that have a value of `None` from the output. + round_trip: Whether to enable serialization and deserialization round-trip support. + warnings: Whether to log warnings when invalid fields are encountered. + + Returns: + A dictionary representation of the model. + """ + if mode != "python": + raise ValueError("mode is only supported in Pydantic v2") + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + return super().dict( # pyright: ignore[reportDeprecated] + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + def model_dump_json( + self, + *, + indent: int | None = None, + include: IncEx = None, + exclude: IncEx = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool = True, + ) -> str: + """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json + + Generates a JSON representation of the model using Pydantic's `to_json` method. + + Args: + indent: Indentation to use in the JSON output. If None is passed, the output will be compact. + include: Field(s) to include in the JSON output. Can take either a string or set of strings. + exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings. + by_alias: Whether to serialize using field aliases. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that have the default value. + exclude_none: Whether to exclude fields that have a value of `None`. + round_trip: Whether to use serialization/deserialization between JSON and class instance. + warnings: Whether to show any warnings that occurred during serialization. + + Returns: + A JSON string representation of the model. + """ + if round_trip != False: + raise ValueError("round_trip is only supported in Pydantic v2") + if warnings != True: + raise ValueError("warnings is only supported in Pydantic v2") + return super().json( # type: ignore[reportDeprecated] + indent=indent, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: diff --git a/src/finch/_types.py b/src/finch/_types.py index cb759eb2..a48c2298 100644 --- a/src/finch/_types.py +++ b/src/finch/_types.py @@ -14,7 +14,7 @@ Optional, Sequence, ) -from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable +from typing_extensions import Literal, Protocol, TypeAlias, TypedDict, runtime_checkable import httpx import pydantic @@ -157,3 +157,7 @@ def get(self, __key: str) -> str | None: ) StrBytesIntFloat = Union[str, bytes, int, float] + +# Note: copied from Pydantic +# https://github.com/pydantic/pydantic/blob/32ea570bf96e84234d2992e1ddf40ab8a565925a/pydantic/main.py#L49 +IncEx: TypeAlias = "set[int] | set[str] | dict[int, Any] | dict[str, Any] | None" diff --git a/tests/test_models.py b/tests/test_models.py index e6b67abe..fa5ddba7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,10 @@ +import json from typing import Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone from typing_extensions import Literal import pytest +import pydantic from pydantic import Field from finch._compat import PYDANTIC_V2, parse_obj, model_dump, model_json @@ -485,3 +487,76 @@ class Model(BaseModel): m = Model.construct(resource_id="foo") assert "resource_id" in m.model_fields_set + + +def test_forwards_compat_model_dump_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert m.model_dump() == {"foo": "hello"} + assert m.model_dump(include={"bar"}) == {} + assert m.model_dump(exclude={"foo"}) == {} + assert m.model_dump(by_alias=True) == {"FOO": "hello"} + + m2 = Model() + assert m2.model_dump() == {"foo": None} + assert m2.model_dump(exclude_unset=True) == {} + assert m2.model_dump(exclude_none=True) == {} + assert m2.model_dump(exclude_defaults=True) == {} + + m3 = Model(FOO=None) + assert m3.model_dump() == {"foo": None} + assert m3.model_dump(exclude_none=True) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="mode is only supported in Pydantic v2"): + m.model_dump(mode="json") + + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump(warnings=False) + + +def test_forwards_compat_model_dump_json_method() -> None: + class Model(BaseModel): + foo: Optional[str] = Field(alias="FOO", default=None) + + m = Model(FOO="hello") + assert json.loads(m.model_dump_json()) == {"foo": "hello"} + assert json.loads(m.model_dump_json(include={"bar"})) == {} + assert json.loads(m.model_dump_json(include={"foo"})) == {"foo": "hello"} + assert json.loads(m.model_dump_json(by_alias=True)) == {"FOO": "hello"} + + assert m.model_dump_json(indent=2) == '{\n "foo": "hello"\n}' + + m2 = Model() + assert json.loads(m2.model_dump_json()) == {"foo": None} + assert json.loads(m2.model_dump_json(exclude_unset=True)) == {} + assert json.loads(m2.model_dump_json(exclude_none=True)) == {} + assert json.loads(m2.model_dump_json(exclude_defaults=True)) == {} + + m3 = Model(FOO=None) + assert json.loads(m3.model_dump_json()) == {"foo": None} + assert json.loads(m3.model_dump_json(exclude_none=True)) == {} + + if not PYDANTIC_V2: + with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): + m.model_dump_json(round_trip=True) + + with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): + m.model_dump_json(warnings=False) + + +def test_type_compat() -> None: + # our model type can be assigned to Pydantic's model type + + def takes_pydantic(model: pydantic.BaseModel) -> None: + ... + + class OurModel(BaseModel): + foo: Optional[str] = None + + takes_pydantic(OurModel())