Files
florianfischer91 775f2d8b32 fix field inheritance (#4911)
* 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>
2023-01-05 12:32:35 +00:00

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": {}}'