mirror of
https://github.com/kennethreitz/pydantic.git
synced 2026-06-05 23:00:18 +00:00
775f2d8b32
* first try to fix inherited fields * handle different design of __annotations__ in py39 and older * simplify getting inherited field from base class * add extra, very simple test * implement suggestions * fix merge Co-authored-by: Samuel Colvin <s@muelcolvin.com>
387 lines
12 KiB
Python
387 lines
12 KiB
Python
import datetime
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass as vanilla_dataclass
|
|
from decimal import Decimal
|
|
from enum import Enum
|
|
from ipaddress import IPv4Address, IPv4Interface, IPv4Network, IPv6Address, IPv6Interface, IPv6Network
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
|
|
from pydantic import BaseModel, NameEmail, create_model
|
|
from pydantic.color import Color
|
|
from pydantic.dataclasses import dataclass as pydantic_dataclass
|
|
from pydantic.json import pydantic_encoder, timedelta_isoformat
|
|
from pydantic.types import DirectoryPath, FilePath, SecretBytes, SecretStr
|
|
|
|
|
|
class MyEnum(Enum):
|
|
foo = 'bar'
|
|
snap = 'crackle'
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2', strict=False)
|
|
@pytest.mark.parametrize(
|
|
'gen_input,output',
|
|
[
|
|
(lambda: UUID('ebcdab58-6eb8-46fb-a190-d07a33e9eac8'), '"ebcdab58-6eb8-46fb-a190-d07a33e9eac8"'),
|
|
(lambda: IPv4Address('192.168.0.1'), '"192.168.0.1"'),
|
|
(lambda: Color('#000'), '"black"'),
|
|
(lambda: Color((1, 12, 123)), '"#010c7b"'),
|
|
(lambda: SecretStr('abcd'), '"**********"'),
|
|
(lambda: SecretStr(''), '""'),
|
|
(lambda: SecretBytes(b'xyz'), '"**********"'),
|
|
(lambda: SecretBytes(b''), '""'),
|
|
(lambda: NameEmail('foo bar', 'foobaR@example.com'), '"foo bar <foobaR@example.com>"'),
|
|
(lambda: IPv6Address('::1:0:1'), '"::1:0:1"'),
|
|
(lambda: IPv4Interface('192.168.0.0/24'), '"192.168.0.0/24"'),
|
|
(lambda: IPv6Interface('2001:db00::/120'), '"2001:db00::/120"'),
|
|
(lambda: IPv4Network('192.168.0.0/24'), '"192.168.0.0/24"'),
|
|
(lambda: IPv6Network('2001:db00::/120'), '"2001:db00::/120"'),
|
|
(lambda: datetime.datetime(2032, 1, 1, 1, 1), '"2032-01-01T01:01:00"'),
|
|
(lambda: datetime.datetime(2032, 1, 1, 1, 1, tzinfo=datetime.timezone.utc), '"2032-01-01T01:01:00+00:00"'),
|
|
(lambda: datetime.datetime(2032, 1, 1), '"2032-01-01T00:00:00"'),
|
|
(lambda: datetime.time(12, 34, 56), '"12:34:56"'),
|
|
(lambda: datetime.timedelta(days=12, seconds=34, microseconds=56), '1036834.000056'),
|
|
(lambda: datetime.timedelta(seconds=-1), '-1.0'),
|
|
(lambda: {1, 2, 3}, '[1, 2, 3]'),
|
|
(lambda: frozenset([1, 2, 3]), '[1, 2, 3]'),
|
|
(lambda: (v for v in range(4)), '[0, 1, 2, 3]'),
|
|
(lambda: b'this is bytes', '"this is bytes"'),
|
|
(lambda: Decimal('12.34'), '12.34'),
|
|
(lambda: create_model('BarModel', a='b', c='d')(), '{"a": "b", "c": "d"}'),
|
|
(lambda: MyEnum.foo, '"bar"'),
|
|
(lambda: re.compile('^regex$'), '"^regex$"'),
|
|
],
|
|
)
|
|
def test_encoding(gen_input, output):
|
|
assert output == json.dumps(gen_input(), default=pydantic_encoder)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith('win'), reason='paths look different on windows')
|
|
def test_path_encoding(tmpdir):
|
|
class PathModel(BaseModel):
|
|
path: Path
|
|
file_path: FilePath
|
|
dir_path: DirectoryPath
|
|
|
|
tmpdir = Path(tmpdir)
|
|
file_path = tmpdir / 'bar'
|
|
file_path.touch()
|
|
dir_path = tmpdir / 'baz'
|
|
dir_path.mkdir()
|
|
model = PathModel(path=Path('/path/test/example/'), file_path=file_path, dir_path=dir_path)
|
|
expected = f'{{"path": "/path/test/example", "file_path": "{file_path}", "dir_path": "{dir_path}"}}'
|
|
assert json.dumps(model, default=pydantic_encoder) == expected
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_model_encoding():
|
|
class ModelA(BaseModel):
|
|
x: int
|
|
y: str
|
|
|
|
class Model(BaseModel):
|
|
a: float
|
|
b: bytes
|
|
c: Decimal
|
|
d: ModelA
|
|
|
|
m = Model(a=10.2, b='foobar', c=10.2, d={'x': 123, 'y': '123'})
|
|
assert m.model_dump() == {'a': 10.2, 'b': b'foobar', 'c': Decimal('10.2'), 'd': {'x': 123, 'y': '123'}}
|
|
assert m.model_dump_json() == '{"a": 10.2, "b": "foobar", "c": 10.2, "d": {"x": 123, "y": "123"}}'
|
|
assert m.model_dump_json(exclude={'b'}) == '{"a": 10.2, "c": 10.2, "d": {"x": 123, "y": "123"}}'
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_subclass_encoding():
|
|
class SubDate(datetime.datetime):
|
|
pass
|
|
|
|
class Model(BaseModel):
|
|
a: datetime.datetime
|
|
b: SubDate
|
|
|
|
m = Model(a=datetime.datetime(2032, 1, 1, 1, 1), b=SubDate(2020, 2, 29, 12, 30))
|
|
assert m.model_dump() == {'a': datetime.datetime(2032, 1, 1, 1, 1), 'b': SubDate(2020, 2, 29, 12, 30)}
|
|
assert m.model_dump_json() == '{"a": "2032-01-01T01:01:00", "b": "2020-02-29T12:30:00"}'
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_subclass_custom_encoding():
|
|
class SubDate(datetime.datetime):
|
|
pass
|
|
|
|
class SubDelta(datetime.timedelta):
|
|
pass
|
|
|
|
class Model(BaseModel):
|
|
a: SubDate
|
|
b: SubDelta
|
|
|
|
class Config:
|
|
json_encoders = {
|
|
datetime.datetime: lambda v: v.strftime('%a, %d %b %C %H:%M:%S'),
|
|
datetime.timedelta: timedelta_isoformat,
|
|
}
|
|
|
|
m = Model(a=SubDate(2032, 1, 1, 1, 1), b=SubDelta(hours=100))
|
|
assert m.model_dump() == {'a': SubDate(2032, 1, 1, 1, 1), 'b': SubDelta(days=4, seconds=14400)}
|
|
assert m.model_dump_json() == '{"a": "Thu, 01 Jan 20 01:01:00", "b": "P4DT4H0M0.000000S"}'
|
|
|
|
|
|
def test_invalid_model():
|
|
class Foo:
|
|
pass
|
|
|
|
with pytest.raises(TypeError):
|
|
json.dumps(Foo, default=pydantic_encoder)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'input,output',
|
|
[
|
|
(datetime.timedelta(days=12, seconds=34, microseconds=56), 'P12DT0H0M34.000056S'),
|
|
(datetime.timedelta(days=1001, hours=1, minutes=2, seconds=3, microseconds=654_321), 'P1001DT1H2M3.654321S'),
|
|
(datetime.timedelta(seconds=-1), '-P1DT23H59M59.000000S'),
|
|
(datetime.timedelta(), 'P0DT0H0M0.000000S'),
|
|
],
|
|
)
|
|
def test_iso_timedelta(input, output):
|
|
assert output == timedelta_isoformat(input)
|
|
|
|
|
|
def test_custom_encoder():
|
|
class Model(BaseModel):
|
|
x: datetime.timedelta
|
|
y: Decimal
|
|
z: datetime.date
|
|
|
|
class Config:
|
|
json_encoders = {datetime.timedelta: lambda v: f'{v.total_seconds():0.3f}s', Decimal: lambda v: 'a decimal'}
|
|
|
|
assert (
|
|
Model(x=123, y=5, z='2032-06-01').model_dump_json() == '{"x": "123.000s", "y": "a decimal", "z": "2032-06-01"}'
|
|
)
|
|
|
|
|
|
def test_custom_iso_timedelta():
|
|
class Model(BaseModel):
|
|
x: datetime.timedelta
|
|
|
|
class Config:
|
|
json_encoders = {datetime.timedelta: timedelta_isoformat}
|
|
|
|
m = Model(x=123)
|
|
assert m.model_dump_json() == '{"x": "P0DT0H2M3.000000S"}'
|
|
|
|
|
|
# def test_con_decimal_encode() -> None:
|
|
# """
|
|
# Makes sure a decimal with decimal_places = 0, as well as one with places
|
|
# can handle a encode/decode roundtrip.
|
|
# """
|
|
#
|
|
# class Id(ConstrainedDecimal):
|
|
# max_digits = 22
|
|
# decimal_places = 0
|
|
# ge = 0
|
|
#
|
|
# class Obj(BaseModel):
|
|
# id: Id
|
|
# price: Decimal = Decimal('0.01')
|
|
#
|
|
# assert Obj(id=1).model_dump_json() == '{"id": 1, "price": 0.01}'
|
|
# assert Obj.parse_raw('{"id": 1, "price": 0.01}') == Obj(id=1)
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_json_encoder_simple_inheritance():
|
|
class Parent(BaseModel):
|
|
dt: datetime.datetime = datetime.datetime.now()
|
|
timedt: datetime.timedelta = datetime.timedelta(hours=100)
|
|
|
|
class Config:
|
|
json_encoders = {datetime.datetime: lambda _: 'parent_encoder'}
|
|
|
|
class Child(Parent):
|
|
class Config:
|
|
json_encoders = {datetime.timedelta: lambda _: 'child_encoder'}
|
|
|
|
assert Child().model_dump_json() == '{"dt": "parent_encoder", "timedt": "child_encoder"}'
|
|
|
|
|
|
def test_json_encoder_inheritance_override():
|
|
class Parent(BaseModel):
|
|
dt: datetime.datetime = datetime.datetime.now()
|
|
|
|
class Config:
|
|
json_encoders = {datetime.datetime: lambda _: 'parent_encoder'}
|
|
|
|
class Child(Parent):
|
|
class Config:
|
|
json_encoders = {datetime.datetime: lambda _: 'child_encoder'}
|
|
|
|
assert Child().model_dump_json() == '{"dt": "child_encoder"}'
|
|
|
|
|
|
def test_custom_encoder_arg():
|
|
class Model(BaseModel):
|
|
x: datetime.timedelta
|
|
|
|
m = Model(x=123)
|
|
assert m.model_dump_json() == '{"x": 123.0}'
|
|
assert m.model_dump_json(encoder=lambda v: '__default__') == '{"x": "__default__"}'
|
|
|
|
|
|
def test_encode_dataclass():
|
|
@vanilla_dataclass
|
|
class Foo:
|
|
bar: int
|
|
spam: str
|
|
|
|
f = Foo(bar=123, spam='apple pie')
|
|
assert '{"bar": 123, "spam": "apple pie"}' == json.dumps(f, default=pydantic_encoder)
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_encode_pydantic_dataclass():
|
|
@pydantic_dataclass
|
|
class Foo:
|
|
bar: int
|
|
spam: str
|
|
|
|
f = Foo(bar=123, spam='apple pie')
|
|
assert '{"bar": 123, "spam": "apple pie"}' == json.dumps(f, default=pydantic_encoder)
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_encode_custom_root():
|
|
class Model(BaseModel):
|
|
__root__: List[str]
|
|
|
|
assert Model(__root__=['a', 'b']).model_dump_json() == '["a", "b"]'
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_custom_decode_encode():
|
|
load_calls, dump_calls = 0, 0
|
|
|
|
def custom_loads(s):
|
|
nonlocal load_calls
|
|
load_calls += 1
|
|
return json.loads(s.strip('$'))
|
|
|
|
def custom_dumps(s, default=None, **kwargs):
|
|
nonlocal dump_calls
|
|
dump_calls += 1
|
|
return json.dumps(s, default=default, indent=2)
|
|
|
|
class Model(BaseModel):
|
|
a: int
|
|
b: str
|
|
|
|
class Config:
|
|
json_loads = custom_loads
|
|
json_dumps = custom_dumps
|
|
|
|
m = Model.parse_raw('${"a": 1, "b": "foo"}$$')
|
|
assert m.model_dump() == {'a': 1, 'b': 'foo'}
|
|
assert m.model_dump_json() == '{\n "a": 1,\n "b": "foo"\n}'
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_json_nested_encode_models():
|
|
class Phone(BaseModel):
|
|
manufacturer: str
|
|
number: int
|
|
|
|
class User(BaseModel):
|
|
name: str
|
|
SSN: int
|
|
birthday: datetime.datetime
|
|
phone: Phone
|
|
friend: Optional['User'] = None # noqa: F821 # https://github.com/PyCQA/pyflakes/issues/567
|
|
|
|
class Config:
|
|
json_encoders = {
|
|
datetime.datetime: lambda v: v.timestamp(),
|
|
Phone: lambda v: v.number if v else None,
|
|
'User': lambda v: v.SSN,
|
|
}
|
|
|
|
User.model_rebuild()
|
|
|
|
iphone = Phone(manufacturer='Apple', number=18002752273)
|
|
galaxy = Phone(manufacturer='Samsung', number=18007267864)
|
|
|
|
timon = User(
|
|
name='Timon', SSN=123, birthday=datetime.datetime(1993, 6, 1, tzinfo=datetime.timezone.utc), phone=iphone
|
|
)
|
|
pumbaa = User(
|
|
name='Pumbaa', SSN=234, birthday=datetime.datetime(1993, 5, 15, tzinfo=datetime.timezone.utc), phone=galaxy
|
|
)
|
|
|
|
timon.friend = pumbaa
|
|
|
|
assert iphone.model_dump_json(models_as_dict=False) == '{"manufacturer": "Apple", "number": 18002752273}'
|
|
assert (
|
|
pumbaa.model_dump_json(models_as_dict=False)
|
|
== '{"name": "Pumbaa", "SSN": 234, "birthday": 737424000.0, "phone": 18007267864, "friend": null}'
|
|
)
|
|
assert (
|
|
timon.model_dump_json(models_as_dict=False)
|
|
== '{"name": "Timon", "SSN": 123, "birthday": 738892800.0, "phone": 18002752273, "friend": 234}'
|
|
)
|
|
|
|
|
|
def test_custom_encode_fallback_basemodel():
|
|
class MyExoticType:
|
|
pass
|
|
|
|
def custom_encoder(o):
|
|
if isinstance(o, MyExoticType):
|
|
return 'exo'
|
|
raise TypeError('not serialisable')
|
|
|
|
class Foo(BaseModel):
|
|
x: MyExoticType
|
|
|
|
class Config:
|
|
arbitrary_types_allowed = True
|
|
|
|
class Bar(BaseModel):
|
|
foo: Foo
|
|
|
|
assert Bar(foo=Foo(x=MyExoticType())).model_dump_json(encoder=custom_encoder) == '{"foo": {"x": "exo"}}'
|
|
|
|
|
|
def test_custom_encode_error():
|
|
class MyExoticType:
|
|
pass
|
|
|
|
def custom_encoder(o):
|
|
raise TypeError('not serialisable')
|
|
|
|
class Foo(BaseModel):
|
|
x: MyExoticType
|
|
|
|
class Config:
|
|
arbitrary_types_allowed = True
|
|
|
|
with pytest.raises(TypeError, match='not serialisable'):
|
|
Foo(x=MyExoticType()).model_dump_json(encoder=custom_encoder)
|
|
|
|
|
|
@pytest.mark.xfail(reason='working on V2')
|
|
def test_recursive():
|
|
class Model(BaseModel):
|
|
value: Optional[str]
|
|
nested: Optional[BaseModel]
|
|
|
|
assert Model(value=None, nested=Model(value=None)).model_dump_json(exclude_none=True) == '{"nested": {}}'
|