ballistica/tests/test_efro/test_dataclassio.py
2024-04-23 13:44:40 -07:00

1434 lines
41 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""Testing dataclasses functionality."""
# pylint: disable=too-many-lines
from __future__ import annotations
import copy
import datetime
from enum import Enum
from dataclasses import field, dataclass
from typing import (
TYPE_CHECKING,
Any,
Sequence,
Annotated,
assert_type,
assert_never,
override,
)
import pytest
from efro.util import utc_now
from efro.dataclassio import (
dataclass_validate,
dataclass_from_dict,
dataclass_to_dict,
ioprepped,
ioprep,
IOAttrs,
Codec,
DataclassFieldLookup,
IOExtendedData,
IOMultiType,
)
if TYPE_CHECKING:
from typing import Self
class _EnumTest(Enum):
TEST1 = 'test1'
TEST2 = 'test2'
class _GoodEnum(Enum):
VAL1 = 'val1'
VAL2 = 'val2'
class _GoodEnum2(Enum):
VAL1 = 1
VAL2 = 2
class _BadEnum1(Enum):
VAL1 = 1.23
class _BadEnum2(Enum):
VAL1 = 1
VAL2 = 'val2'
@dataclass
class _NestedClass:
ival: int = 0
sval: str = 'foo'
dval: dict[int, str] = field(default_factory=dict)
# We use utc_now() for a test which is deprecated.
@pytest.mark.filterwarnings('ignore::DeprecationWarning')
def test_assign() -> None:
"""Testing various assignments."""
# pylint: disable=too-many-statements
@ioprepped
@dataclass
class _TestClass:
ival: int = 0
sval: str = ''
bval: bool = True
fval: float = 1.0
nval: _NestedClass = field(default_factory=_NestedClass)
enval: _EnumTest = _EnumTest.TEST1
oival: int | None = None
oival2: int | None = None
osval: str | None = None
obval: bool | None = None
ofval: float | None = None
oenval: _EnumTest | None = _EnumTest.TEST1
lsval: list[str] = field(default_factory=list)
lival: list[int] = field(default_factory=list)
lbval: list[bool] = field(default_factory=list)
lfval: list[float] = field(default_factory=list)
lenval: list[_EnumTest] = field(default_factory=list)
ssval: set[str] = field(default_factory=set)
anyval: Any = 1
dictval: dict[int, str] = field(default_factory=dict)
tupleval: tuple[int, str, bool] = (1, 'foo', False)
datetimeval: datetime.datetime | None = None
timedeltaval: datetime.timedelta | None = None
class _TestClass2:
pass
# Attempting to use with non-dataclass should fail.
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass2, {})
# Attempting to pass non-dicts should fail.
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, []) # type: ignore
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, None) # type: ignore
now = utc_now()
tdelta = datetime.timedelta(days=123, seconds=456, microseconds=789)
# A dict containing *ALL* values should exactly match what we get
# when creating a dataclass and then converting back to a dict.
dict1 = {
'ival': 1,
'sval': 'foo',
'bval': True,
'fval': 2.0,
'nval': {
'ival': 1,
'sval': 'bar',
'dval': {'1': 'foof'},
},
'enval': 'test1',
'oival': 1,
'oival2': 1,
'osval': 'foo',
'obval': True,
'ofval': 1.0,
'oenval': 'test2',
'lsval': ['foo'],
'lival': [10],
'lbval': [False],
'lfval': [1.0],
'lenval': ['test1', 'test2'],
'ssval': ['foo'],
'dval': {'k': 123},
'anyval': {'foo': [1, 2, {'bar': 'eep', 'rah': 1}]},
'dictval': {'1': 'foo'},
'tupleval': [2, 'foof', True],
'datetimeval': [
now.year,
now.month,
now.day,
now.hour,
now.minute,
now.second,
now.microsecond,
],
'timedeltaval': [tdelta.days, tdelta.seconds, tdelta.microseconds],
}
dc1 = dataclass_from_dict(_TestClass, dict1)
assert dataclass_to_dict(dc1) == dict1
# A few other assignment checks.
assert isinstance(
dataclass_from_dict(
_TestClass,
{
'oival': None,
'oival2': None,
'osval': None,
'obval': None,
'ofval': None,
'lsval': [],
'lival': [],
'lbval': [],
'lfval': [],
'ssval': [],
},
),
_TestClass,
)
assert isinstance(
dataclass_from_dict(
_TestClass,
{
'oival': 1,
'oival2': 1,
'osval': 'foo',
'obval': True,
'ofval': 2.0,
'lsval': ['foo', 'bar', 'eep'],
'lival': [10, 11, 12],
'lbval': [False, True],
'lfval': [1.0, 2.0, 3.0],
},
),
_TestClass,
)
# Attr assigns mismatched with their value types should fail.
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ival': 'foo'})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'sval': 1})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'bval': 2})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'oival': 'foo'})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'oival2': 'foo'})
dataclass_from_dict(_TestClass, {'oival2': None})
dataclass_from_dict(_TestClass, {'oival2': 123})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'osval': 1})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'obval': 2})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ofval': 'blah'})
with pytest.raises(ValueError):
dataclass_from_dict(_TestClass, {'oenval': 'test3'})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lsval': 'blah'})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lsval': ['blah', None]})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lsval': [1]})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lsval': (1,)})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lbval': [None]})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lival': ['foo']})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lfval': [True]})
with pytest.raises(ValueError):
dataclass_from_dict(_TestClass, {'lenval': ['test1', 'test3']})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ssval': [True]})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ssval': {}})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ssval': set()})
with pytest.raises(ValueError):
dataclass_from_dict(_TestClass, {'tupleval': []})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'tupleval': [1, 1, 1]})
with pytest.raises(ValueError):
dataclass_from_dict(_TestClass, {'tupleval': [2, 'foof', True, True]})
# Fields with type Any should accept all types which are directly
# supported by json, but not ones such as tuples or non-string dict keys
# which get implicitly translated by python's json module.
dataclass_from_dict(_TestClass, {'anyval': {}})
dataclass_from_dict(_TestClass, {'anyval': None})
dataclass_from_dict(_TestClass, {'anyval': []})
dataclass_from_dict(_TestClass, {'anyval': [True, {'foo': 'bar'}, None]})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'anyval': {1: 'foo'}})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'anyval': set()})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'anyval': (1, 2, 3)})
# More subtle attr/type mismatches that should fail
# (we currently require EXACT type matches).
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ival': True})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'fval': 2}, coerce_to_float=False)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'bval': 1})
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ofval': 1}, coerce_to_float=False)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'lfval': [1]}, coerce_to_float=False)
# Coerce-to-float should only work on ints; not bools or other types.
dataclass_from_dict(_TestClass, {'fval': 1}, coerce_to_float=True)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'fval': 1}, coerce_to_float=False)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'fval': True}, coerce_to_float=True)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'fval': None}, coerce_to_float=True)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'fval': []}, coerce_to_float=True)
# Datetime values should only be allowed with timezone set as utc.
dataclass_to_dict(_TestClass(datetimeval=utc_now()))
with pytest.raises(ValueError):
dataclass_to_dict(_TestClass(datetimeval=datetime.datetime.now()))
with pytest.raises(ValueError):
# This doesn't actually set timezone on the datetime obj.
dataclass_to_dict(_TestClass(datetimeval=datetime.datetime.utcnow()))
def test_coerce() -> None:
"""Test value coercion."""
@ioprepped
@dataclass
class _TestClass:
ival: int = 0
fval: float = 0.0
# Float value present for int should never work.
obj = _TestClass()
# noinspection PyTypeHints
obj.ival = 1.0 # type: ignore
with pytest.raises(TypeError):
dataclass_validate(obj, coerce_to_float=True)
with pytest.raises(TypeError):
dataclass_validate(obj, coerce_to_float=False)
# Int value present for float should work only with coerce on.
obj = _TestClass()
obj.fval = 1
dataclass_validate(obj, coerce_to_float=True)
with pytest.raises(TypeError):
dataclass_validate(obj, coerce_to_float=False)
# Likewise, passing in an int for a float field should work only
# with coerce on.
dataclass_from_dict(_TestClass, {'fval': 1}, coerce_to_float=True)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'fval': 1}, coerce_to_float=False)
# Passing in floats for an int field should never work.
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ival': 1.0}, coerce_to_float=True)
with pytest.raises(TypeError):
dataclass_from_dict(_TestClass, {'ival': 1.0}, coerce_to_float=False)
def test_prep() -> None:
"""Test the prepping process."""
# We currently don't support Sequence; can revisit if there is
# a strong use case.
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass:
ival: Sequence[int]
# We currently only support Unions with exactly 2 members; one of
# which is None. (Optional types get transformed into this by
# get_type_hints() so we need to support at least that).
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass2:
ival: int | str
@ioprepped
@dataclass
class _TestClass3:
uval: int | None
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass4:
ival: int | str
# This will get simplified down to simply int by get_type_hints so is ok.
@ioprepped
@dataclass
class _TestClass5:
ival: int | int
# This will get simplified down to a valid 2 member union so is ok
@ioprepped
@dataclass
class _TestClass6:
ival: int | None | int | None
# Disallow dict entries with types other than str, int, or enums
# having those value types.
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass7:
dval: dict[float, int]
@ioprepped
@dataclass
class _TestClass8:
dval: dict[str, int]
@ioprepped
@dataclass
class _TestClass9:
dval: dict[_GoodEnum, int]
@ioprepped
@dataclass
class _TestClass10:
dval: dict[_GoodEnum2, int]
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass11:
dval: dict[_BadEnum1, int]
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass12:
dval: dict[_BadEnum2, int]
def test_validate() -> None:
"""Testing validation."""
@ioprepped
@dataclass
class _TestClass:
ival: int = 0
sval: str = ''
bval: bool = True
fval: float = 1.0
oival: int | None = None
osval: str | None = None
obval: bool | None = None
ofval: float | None = None
# Should pass by default.
tclass = _TestClass()
dataclass_validate(tclass)
# No longer valid (without coerce)
tclass.fval = 1
with pytest.raises(TypeError):
dataclass_validate(tclass, coerce_to_float=False)
# Should pass by default.
tclass = _TestClass()
dataclass_validate(tclass)
# No longer valid.
# noinspection PyTypeHints
tclass.ival = None # type: ignore
with pytest.raises(TypeError):
dataclass_validate(tclass)
def test_extra_data() -> None:
"""Test handling of data that doesn't map to dataclass attrs."""
@ioprepped
@dataclass
class _TestClass:
ival: int = 0
sval: str = ''
# Passing an attr not in the dataclass should fail if we ask it to.
with pytest.raises(AttributeError):
dataclass_from_dict(
_TestClass, {'nonexistent': 'foo'}, allow_unknown_attrs=False
)
# But normally it should be preserved and present in re-export.
obj = dataclass_from_dict(_TestClass, {'nonexistent': 'foo'})
assert isinstance(obj, _TestClass)
out = dataclass_to_dict(obj)
assert out.get('nonexistent') == 'foo'
# But not if we ask it to discard unknowns.
obj = dataclass_from_dict(
_TestClass, {'nonexistent': 'foo'}, discard_unknown_attrs=True
)
assert isinstance(obj, _TestClass)
out = dataclass_to_dict(obj)
assert 'nonexistent' not in out
def test_ioattrs() -> None:
"""Testing ioattrs annotations."""
@ioprepped
@dataclass
class _TestClass:
dval: Annotated[dict, IOAttrs('d')]
obj = _TestClass(dval={'foo': 'bar'})
# Make sure key is working.
assert dataclass_to_dict(obj) == {'d': {'foo': 'bar'}}
# Setting store_default False without providing a default or
# default_factory should fail.
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass2:
dval: Annotated[dict, IOAttrs('d', store_default=False)]
@ioprepped
@dataclass
class _TestClass3:
dval: Annotated[dict, IOAttrs('d', store_default=False)] = field(
default_factory=dict
)
ival: Annotated[int, IOAttrs('i', store_default=False)] = 123
# Both attrs are default; should get stripped out.
obj3 = _TestClass3()
assert dataclass_to_dict(obj3) == {}
# Both attrs are non-default vals; should remain in output.
obj3 = _TestClass3(dval={'foo': 'bar'}, ival=124)
assert dataclass_to_dict(obj3) == {'d': {'foo': 'bar'}, 'i': 124}
# Test going the other way.
obj3 = dataclass_from_dict(
_TestClass3,
{'d': {'foo': 'barf'}, 'i': 125},
allow_unknown_attrs=False,
)
assert obj3.dval == {'foo': 'barf'}
assert obj3.ival == 125
def test_codecs() -> None:
"""Test differences with codecs."""
@ioprepped
@dataclass
class _TestClass:
bval: bytes
# bytes to/from JSON (goes through base64)
obj = _TestClass(bval=b'foo')
out = dataclass_to_dict(obj, codec=Codec.JSON)
assert isinstance(out['bval'], str) and out['bval'] == 'Zm9v'
obj = dataclass_from_dict(_TestClass, out, codec=Codec.JSON)
assert obj.bval == b'foo'
# bytes to/from FIRESTORE (passed as-is)
obj = _TestClass(bval=b'foo')
out = dataclass_to_dict(obj, codec=Codec.FIRESTORE)
assert isinstance(out['bval'], bytes) and out['bval'] == b'foo'
obj = dataclass_from_dict(_TestClass, out, codec=Codec.FIRESTORE)
assert obj.bval == b'foo'
now = utc_now()
@ioprepped
@dataclass
class _TestClass2:
dval: datetime.datetime
# datetime to/from JSON (turns into a list of values)
obj2 = _TestClass2(dval=now)
out = dataclass_to_dict(obj2, codec=Codec.JSON)
assert (
isinstance(out['dval'], list)
and len(out['dval']) == 7
and all(isinstance(val, int) for val in out['dval'])
)
obj2 = dataclass_from_dict(_TestClass2, out, codec=Codec.JSON)
assert obj2.dval == now
# datetime to/from FIRESTORE (passed through as-is)
obj2 = _TestClass2(dval=now)
out = dataclass_to_dict(obj2, codec=Codec.FIRESTORE)
assert isinstance(out['dval'], datetime.datetime)
obj2 = dataclass_from_dict(_TestClass2, out, codec=Codec.FIRESTORE)
assert obj2.dval == now
def test_dict() -> None:
"""Test various dict related bits."""
@ioprepped
@dataclass
class _TestClass:
dval: dict
obj = _TestClass(dval={})
# 'Any' dicts should only support values directly compatible with json.
obj.dval['foo'] = 5
dataclass_to_dict(obj)
with pytest.raises(TypeError):
obj.dval[5] = 5
dataclass_to_dict(obj)
with pytest.raises(TypeError):
obj.dval['foo'] = _GoodEnum.VAL1
dataclass_to_dict(obj)
# Int dict-keys should actually be stored as strings internally
# (for json compatibility).
@ioprepped
@dataclass
class _TestClass2:
dval: dict[int, float]
obj2 = _TestClass2(dval={1: 2.34})
out = dataclass_to_dict(obj2)
assert '1' in out['dval']
assert 1 not in out['dval']
out['dval']['1'] = 2.35
obj2 = dataclass_from_dict(_TestClass2, out)
assert isinstance(obj2, _TestClass2)
assert obj2.dval[1] == 2.35
# Same with enum keys (we support enums with str and int values)
@ioprepped
@dataclass
class _TestClass3:
dval: dict[_GoodEnum, int]
obj3 = _TestClass3(dval={_GoodEnum.VAL1: 123})
out = dataclass_to_dict(obj3)
assert out['dval']['val1'] == 123
out['dval']['val1'] = 124
obj3 = dataclass_from_dict(_TestClass3, out)
assert obj3.dval[_GoodEnum.VAL1] == 124
@ioprepped
@dataclass
class _TestClass4:
dval: dict[_GoodEnum2, int]
obj4 = _TestClass4(dval={_GoodEnum2.VAL1: 125})
out = dataclass_to_dict(obj4)
assert out['dval']['1'] == 125
out['dval']['1'] = 126
obj4 = dataclass_from_dict(_TestClass4, out)
assert obj4.dval[_GoodEnum2.VAL1] == 126
# The wrong enum type as a key should error.
obj4.dval = {_GoodEnum.VAL1: 999} # type: ignore
with pytest.raises(TypeError):
dataclass_to_dict(obj4)
def test_sets() -> None:
"""Test bits related to sets."""
@ioprepped
@dataclass
class _TestClass:
sval: set[str]
obj1 = _TestClass({'a', 'b', 'c', 'd', 'e', 'f'})
obj2 = _TestClass({'c', 'd', 'a', 'e', 'f', 'b'})
# Sets get converted to lists; make sure they are getting sorted so
# that output is deterministic and it is meaningful to compare the
# output dicts from two sets for equality.
assert dataclass_to_dict(obj1) == {'sval': ['a', 'b', 'c', 'd', 'e', 'f']}
assert dataclass_to_dict(obj2) == {'sval': ['a', 'b', 'c', 'd', 'e', 'f']}
def test_name_clashes() -> None:
"""Make sure we catch name clashes since we can remap attr names."""
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass:
ival: Annotated[int, IOAttrs('i')] = 4
ival2: Annotated[int, IOAttrs('i')] = 5
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass2:
ival: int = 4
ival2: Annotated[int, IOAttrs('ival')] = 5
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClass3:
ival: Annotated[int, IOAttrs(store_default=False)] = 4
ival2: Annotated[int, IOAttrs('ival')] = 5
@dataclass
class _RecursiveTest:
val: int
child: _RecursiveTest | None = None
def test_recursive() -> None:
"""Test recursive classes."""
# Can't use ioprepped on this since it refers to its own name which
# doesn't exist yet. Have to explicitly prep it after.
ioprep(_RecursiveTest)
rtest = _RecursiveTest(val=1)
rtest.child = _RecursiveTest(val=2)
rtest.child.child = _RecursiveTest(val=3)
expected_output = {
'val': 1,
'child': {'val': 2, 'child': {'val': 3, 'child': None}},
}
assert dataclass_to_dict(rtest) == expected_output
assert dataclass_from_dict(_RecursiveTest, expected_output) == rtest
def test_any() -> None:
"""Test data included with type Any."""
@ioprepped
@dataclass
class _TestClass:
anyval: Any
obj = _TestClass(anyval=b'bytes')
# JSON output doesn't allow bytes or datetime objects
# included in 'Any' data.
with pytest.raises(TypeError):
dataclass_validate(obj, codec=Codec.JSON)
obj.anyval = datetime.datetime.now()
with pytest.raises(TypeError):
dataclass_validate(obj, codec=Codec.JSON)
# Firestore, however, does.
obj.anyval = b'bytes'
dataclass_validate(obj, codec=Codec.FIRESTORE)
obj.anyval = datetime.datetime.now()
dataclass_validate(obj, codec=Codec.FIRESTORE)
@ioprepped
@dataclass
class _SPTestClass1:
barf: int = 5
eep: str = 'blah'
barf2: Annotated[int, IOAttrs('b')] = 5
@ioprepped
@dataclass
class _SPTestClass2:
rah: bool = False
subc: _SPTestClass1 = field(default_factory=_SPTestClass1)
subc2: Annotated[_SPTestClass1, IOAttrs('s')] = field(
default_factory=_SPTestClass1
)
def test_datetime_limits() -> None:
"""Test limiting datetime values in various ways."""
from efro.util import utc_today, utc_this_hour
@ioprepped
@dataclass
class _TestClass:
tval: Annotated[datetime.datetime, IOAttrs(whole_hours=True)]
# Check whole-hour limit when validating/exporting.
obj = _TestClass(tval=utc_this_hour() + datetime.timedelta(minutes=1))
with pytest.raises(ValueError):
dataclass_validate(obj)
obj.tval = utc_this_hour()
dataclass_validate(obj)
# Check whole-days limit when importing.
out = dataclass_to_dict(obj)
out['tval'][-1] += 1
with pytest.raises(ValueError):
dataclass_from_dict(_TestClass, out)
# Check whole-days limit when validating/exporting.
@ioprepped
@dataclass
class _TestClass2:
tval: Annotated[datetime.datetime, IOAttrs(whole_days=True)]
obj2 = _TestClass2(tval=utc_today() + datetime.timedelta(hours=1))
with pytest.raises(ValueError):
dataclass_validate(obj2)
obj2.tval = utc_today()
dataclass_validate(obj2)
# Check whole-days limit when importing.
out = dataclass_to_dict(obj2)
out['tval'][-1] += 1
with pytest.raises(ValueError):
dataclass_from_dict(_TestClass2, out)
def test_field_paths() -> None:
"""Test type-safe field path evaluations."""
# Define a few nested dataclass types, some of which
# have storage names differing from their field names.
@ioprepped
@dataclass
class _TestClass:
@dataclass
class _TestSubClass:
val1: int = 0
val2: Annotated[int, IOAttrs('v2')] = 0
sub1: _TestSubClass = field(default_factory=_TestSubClass)
sub2: Annotated[_TestSubClass, IOAttrs('s2')] = field(
default_factory=_TestSubClass
)
# Now let's lookup various storage paths.
lookup = DataclassFieldLookup(_TestClass)
# Make sure lookups are returning correct storage paths.
assert lookup.path(lambda obj: obj.sub1) == 'sub1'
assert lookup.path(lambda obj: obj.sub1.val1) == 'sub1.val1'
assert lookup.path(lambda obj: obj.sub1.val2) == 'sub1.v2'
assert lookup.path(lambda obj: obj.sub2.val1) == 's2.val1'
assert lookup.path(lambda obj: obj.sub2.val2) == 's2.v2'
# Attempting to return fields that aren't there should fail
# in both type-checking and runtime.
with pytest.raises(AttributeError):
lookup.path(lambda obj: obj.sub1.val3) # type: ignore
# Returning non-field objects will fail at runtime
# even if type-checking evaluates them as valid values.
with pytest.raises(TypeError):
lookup.path(lambda obj: 1)
with pytest.raises(TypeError):
lookup.path(lambda obj: obj.sub1.val1.real)
def test_nested() -> None:
"""Test nesting dataclasses."""
@ioprepped
@dataclass
class _TestClass:
class _TestEnum(Enum):
VAL1 = 'val1'
VAL2 = 'val2'
@dataclass
class _TestSubClass:
ival: int = 0
subval: _TestSubClass = field(default_factory=_TestSubClass)
enval: _TestEnum = _TestEnum.VAL1
def test_extended_data() -> None:
"""Test IOExtendedData functionality."""
@ioprepped
@dataclass
class _TestClass:
vals: tuple[int, int]
# This data lines up.
indata = {'vals': [0, 0]}
_obj = dataclass_from_dict(_TestClass, indata)
# This data doesn't.
indata = {'vals': [0, 0, 0]}
with pytest.raises(ValueError):
_obj = dataclass_from_dict(_TestClass, indata)
# Now define the same data but give it an adapter
# so it can work with our incorrectly-formatted data.
@ioprepped
@dataclass
class _TestClass2(IOExtendedData):
vals: tuple[int, int]
@override
@classmethod
def will_input(cls, data: dict) -> None:
data['vals'] = data['vals'][:2]
@override
def will_output(self) -> None:
self.vals = (0, 0)
# This data lines up.
indata = {'vals': [0, 0]}
_obj2 = dataclass_from_dict(_TestClass2, indata)
# Now this data will too via our custom input filter.
indata = {'vals': [0, 0, 0]}
_obj2 = dataclass_from_dict(_TestClass2, indata)
# Ok, now test output:
# Does the expected thing.
assert dataclass_to_dict(_TestClass(vals=(1, 2))) == {'vals': [1, 2]}
# Uses our output filter.
assert dataclass_to_dict(_TestClass2(vals=(1, 2))) == {'vals': [0, 0]}
def test_soft_default() -> None:
"""Test soft_default IOAttr value."""
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# Try both of these with and without storage_name to make sure
# soft_default interacts correctly with both cases.
@ioprepped
@dataclass
class _TestClassA:
ival: int
@ioprepped
@dataclass
class _TestClassA2:
ival: Annotated[int, IOAttrs('i')]
@ioprepped
@dataclass
class _TestClassB:
ival: Annotated[int, IOAttrs(soft_default=0)]
@ioprepped
@dataclass
class _TestClassB2:
ival: Annotated[int, IOAttrs('i', soft_default=0)]
@ioprepped
@dataclass
class _TestClassB3:
ival: Annotated[int, IOAttrs('i', soft_default_factory=lambda: 0)]
# These should fail because there's no value for ival.
with pytest.raises(ValueError):
dataclass_from_dict(_TestClassA, {})
with pytest.raises(ValueError):
dataclass_from_dict(_TestClassA2, {})
# These should succeed because it has a soft-default value to
# fall back on.
dataclass_from_dict(_TestClassB, {})
dataclass_from_dict(_TestClassB2, {})
dataclass_from_dict(_TestClassB3, {})
# soft_default should also allow using store_default=False without
# requiring the dataclass to contain a default or default_factory
@ioprepped
@dataclass
class _TestClassC:
ival: Annotated[int, IOAttrs(store_default=False)] = 0
assert dataclass_to_dict(_TestClassC()) == {}
# This should fail since store_default would be meaningless without
# any source for the default value.
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClassC2:
ival: Annotated[int, IOAttrs(store_default=False)]
# However with our shiny soft_default it should work.
@ioprepped
@dataclass
class _TestClassC3:
ival: Annotated[int, IOAttrs(store_default=False, soft_default=0)]
assert dataclass_to_dict(_TestClassC3(0)) == {}
@ioprepped
@dataclass
class _TestClassC3b:
ival: Annotated[
int, IOAttrs(store_default=False, soft_default_factory=lambda: 0)
]
assert dataclass_to_dict(_TestClassC3b(0)) == {}
# We disallow passing a few mutable types as soft_defaults
# just as dataclass does with regular defaults.
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClassD:
lval: Annotated[list, IOAttrs(soft_default=[])]
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClassD2:
# noinspection PyTypeHints
lval: Annotated[set, IOAttrs(soft_default=set())]
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClassD3:
lval: Annotated[dict, IOAttrs(soft_default={})]
# soft_defaults are not static-type-checked, but we do try to
# catch basic type mismatches at prep time. Make sure that's working.
# (we also do full value validation during input, but the more we catch
# early the better)
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClassE:
lval: Annotated[int, IOAttrs(soft_default='')]
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClassE2:
lval: Annotated[str, IOAttrs(soft_default=45)]
with pytest.raises(TypeError):
@ioprepped
@dataclass
class _TestClassE3:
lval: Annotated[list, IOAttrs(soft_default_factory=set)]
# Make sure Unions/Optionals go through ok.
# (note that mismatches currently aren't caught at prep time; just
# checking the negative case here).
@ioprepped
@dataclass
class _TestClassE4:
lval: Annotated[str | None, IOAttrs(soft_default=None)]
@ioprepped
@dataclass
class _TestClassE5:
lval: Annotated[str | None, IOAttrs(soft_default='foo')]
# Now try more in-depth examples: nested type mismatches like this
# are currently not caught at prep-time but ARE caught during inputting.
@ioprepped
@dataclass
class _TestClassE6:
lval: Annotated[tuple[int, int], IOAttrs(soft_default=('foo', 'bar'))]
with pytest.raises(TypeError):
dataclass_from_dict(_TestClassE6, {})
@ioprepped
@dataclass
class _TestClassE7:
lval: Annotated[bool | None, IOAttrs(soft_default=12)]
with pytest.raises(TypeError):
dataclass_from_dict(_TestClassE7, {})
# If both a soft_default and regular field default are present,
# make sure soft_default takes precedence (it applies before
# data even hits the dataclass constructor).
@ioprepped
@dataclass
class _TestClassE8:
ival: Annotated[int, IOAttrs(soft_default=1, store_default=False)] = 2
assert dataclass_from_dict(_TestClassE8, {}).ival == 1
# Make sure soft_default gets used both when determining when
# to omit values from output and what to recreate missing values as.
orig = _TestClassE8(ival=1)
todict = dataclass_to_dict(orig)
assert todict == {}
assert dataclass_from_dict(_TestClassE8, todict) == orig
# Instantiate with the dataclass default and it should still get
# explicitly despite the store_default=False because soft_default
# takes precedence.
orig = _TestClassE8()
todict = dataclass_to_dict(orig)
assert todict == {'ival': 2}
assert dataclass_from_dict(_TestClassE8, todict) == orig
class MTTestTypeID(Enum):
"""IDs for our multi-type class."""
CLASS_1 = 'm1'
CLASS_2 = 'm2'
class MTTestBase(IOMultiType[MTTestTypeID]):
"""Our multi-type class.
These top level multi-type classes are special parent classes
that know about all of their child classes and how to serialize
& deserialize them using explicit type ids. We can then use the
parent class in annotations and dataclassio will do the right thing.
Useful for stuff like Message classes where we may want to store a
bunch of different types of them into one place.
"""
@override
@classmethod
def get_type(cls, type_id: MTTestTypeID) -> type[MTTestBase]:
"""Return the subclass for each of our type-ids."""
# This uses assert_never() to ensure we cover all cases in the
# enum. Though this is less efficient than looking up by dict
# would be. If we had lots of values we could also support lazy
# loading by importing classes only when their value is being
# requested.
val: type[MTTestBase]
if type_id is MTTestTypeID.CLASS_1:
val = MTTestClass1
elif type_id is MTTestTypeID.CLASS_2:
val = MTTestClass2
else:
assert_never(type_id)
return val
@override
@classmethod
def get_type_id(cls) -> MTTestTypeID:
"""Provide the type-id for this subclass."""
# If we wanted, we could just maintain a static mapping
# of types-to-ids here, but there are benefits to letting
# each child class speak for itself. Namely that we can
# do lazy-loading and don't need to have all types present
# here.
# So we'll let all our child classes override this.
raise NotImplementedError()
@ioprepped
@dataclass(frozen=True) # Frozen so we can test in set()
class MTTestClass1(MTTestBase):
"""A test child-class for use with our multi-type class."""
ival: int
@override
@classmethod
def get_type_id(cls) -> MTTestTypeID:
return MTTestTypeID.CLASS_1
@ioprepped
@dataclass(frozen=True) # Frozen so we can test in set()
class MTTestClass2(MTTestBase):
"""Another test child-class for use with our multi-type class."""
sval: str
@override
@classmethod
def get_type_id(cls) -> MTTestTypeID:
return MTTestTypeID.CLASS_2
def test_multi_type() -> None:
"""Test IOMultiType stuff."""
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# Test converting single instances back and forth.
val1: MTTestBase = MTTestClass1(ival=123)
tpname = MTTestBase.get_type_id_storage_name()
outdict = dataclass_to_dict(val1)
assert outdict == {'ival': 123, tpname: 'm1'}
val2: MTTestBase = MTTestClass2(sval='whee')
outdict2 = dataclass_to_dict(val2)
assert outdict2 == {'sval': 'whee', tpname: 'm2'}
# Make sure types and values work for both concrete types and the
# multi-type.
assert_type(dataclass_from_dict(MTTestClass1, outdict), MTTestClass1)
assert_type(dataclass_from_dict(MTTestBase, outdict), MTTestBase)
assert dataclass_from_dict(MTTestClass1, outdict) == val1
assert dataclass_from_dict(MTTestClass2, outdict2) == val2
assert dataclass_from_dict(MTTestBase, outdict) == val1
assert dataclass_from_dict(MTTestBase, outdict2) == val2
# Trying to load as a multi-type should fail if there is no type
# value present.
outdictmod = copy.deepcopy(outdict)
del outdictmod[tpname]
with pytest.raises(ValueError):
dataclass_from_dict(MTTestBase, outdictmod)
# However it should work when loading an exact type. This can be
# necessary to gracefully upgrade old data to multi-type form.
dataclass_from_dict(MTTestClass1, outdictmod)
# Now test our multi-type embedded in other classes. We should be
# able to throw a mix of things in there and have them deserialize
# back the types we started with.
# Individual values:
@ioprepped
@dataclass
class _TestContainerClass1:
obj_a: MTTestBase
obj_b: MTTestBase
container1 = _TestContainerClass1(
obj_a=MTTestClass1(234), obj_b=MTTestClass2('987')
)
outdict = dataclass_to_dict(container1)
container1b = dataclass_from_dict(_TestContainerClass1, outdict)
assert container1 == container1b
# Lists:
@ioprepped
@dataclass
class _TestContainerClass2:
objs: list[MTTestBase]
container2 = _TestContainerClass2(
objs=[MTTestClass1(111), MTTestClass2('bbb')]
)
outdict = dataclass_to_dict(container2)
container2b = dataclass_from_dict(_TestContainerClass2, outdict)
assert container2 == container2b
# Dict values:
@ioprepped
@dataclass
class _TestContainerClass3:
objs: dict[int, MTTestBase]
container3 = _TestContainerClass3(
objs={1: MTTestClass1(456), 2: MTTestClass2('gronk')}
)
outdict = dataclass_to_dict(container3)
container3b = dataclass_from_dict(_TestContainerClass3, outdict)
assert container3 == container3b
# Tuples:
@ioprepped
@dataclass
class _TestContainerClass4:
objs: tuple[MTTestBase, MTTestBase]
container4 = _TestContainerClass4(
objs=(MTTestClass1(932), MTTestClass2('potato'))
)
outdict = dataclass_to_dict(container4)
container4b = dataclass_from_dict(_TestContainerClass4, outdict)
assert container4 == container4b
# Sets (note: dataclasses must be frozen for this to work):
@ioprepped
@dataclass
class _TestContainerClass5:
objs: set[MTTestBase]
container5 = _TestContainerClass5(
objs={MTTestClass1(424), MTTestClass2('goo')}
)
outdict = dataclass_to_dict(container5)
container5b = dataclass_from_dict(_TestContainerClass5, outdict)
assert container5 == container5b
# Optionals.
@ioprepped
@dataclass
class _TestContainerClass6:
obj: MTTestBase | None
container6 = _TestContainerClass6(obj=None)
outdict = dataclass_to_dict(container6)
container6b = dataclass_from_dict(_TestContainerClass6, outdict)
assert container6 == container6b
container6 = _TestContainerClass6(obj=MTTestClass2('fwr'))
outdict = dataclass_to_dict(container6)
container6b = dataclass_from_dict(_TestContainerClass6, outdict)
assert container6 == container6b
@ioprepped
@dataclass
class _TestContainerClass7:
obj: Annotated[
MTTestBase | None,
IOAttrs('o', soft_default=None),
]
container7 = _TestContainerClass7(obj=None)
outdict = dataclass_to_dict(container7)
container7b = dataclass_from_dict(_TestContainerClass7, {})
assert container7 == container7b
class MTTest2TypeID(Enum):
"""IDs for our multi-type class."""
CLASS_1 = 'm1'
CLASS_2 = 'm2'
CLASS_3 = 'm3'
class MTTest2Base(IOMultiType[MTTest2TypeID]):
"""Another multi-type test.
This one tests overriding type-id-storage-name.
"""
@override
@classmethod
def get_type_id_storage_name(cls) -> str:
return 'type'
@override
@classmethod
def get_type(cls, type_id: MTTest2TypeID) -> type[MTTest2Base]:
val: type[MTTest2Base]
if type_id is MTTest2TypeID.CLASS_1:
val = MTTest2Class1
elif type_id is MTTest2TypeID.CLASS_2:
val = MTTest2Class2
elif type_id is MTTest2TypeID.CLASS_3:
val = MTTest2Class3
else:
assert_never(type_id)
return val
@override
@classmethod
def get_type_id(cls) -> MTTest2TypeID:
raise NotImplementedError()
@ioprepped
@dataclass
class MTTest2Class1(MTTest2Base):
"""A test child-class for use with our multi-type class."""
ival: int
@override
@classmethod
def get_type_id(cls) -> MTTest2TypeID:
return MTTest2TypeID.CLASS_1
@ioprepped
@dataclass
class MTTest2Class2(MTTest2Base):
"""Another test child-class for use with our multi-type class."""
sval: str
@override
@classmethod
def get_type_id(cls) -> MTTest2TypeID:
return MTTest2TypeID.CLASS_2
@ioprepped
@dataclass
class MTTest2Class3(MTTest2Base):
"""Another test child-class for use with our multi-type class."""
type: str = ''
@override
@classmethod
def get_type_id(cls) -> MTTest2TypeID:
return MTTest2TypeID.CLASS_3
def test_multi_type_2() -> None:
"""Test IOMultiType stuff."""
# Make sure this serializes correctly with 'test' as a type name.
val1: MTTest2Base = MTTest2Class1(ival=123)
outdict = dataclass_to_dict(val1)
assert outdict == {'ival': 123, 'type': 'm1'}
val1b = dataclass_from_dict(MTTest2Base, outdict)
assert val1 == val1b
val2: MTTest2Base = MTTest2Class2(sval='whee')
outdict2 = dataclass_to_dict(val2)
assert outdict2 == {'sval': 'whee', 'type': 'm2'}
val2b = dataclass_from_dict(MTTest2Base, outdict2)
assert val2 == val2b
# If a multi-type class uses 'type' itself, make sure we error
# instead of letting things break due to the name clash. In an ideal
# world this would error at prep time, but IOMultiType is built
# around lazy-loading so it can't actually examine all types at that
# time.
# Make sure we error on output...
val3: MTTest2Base = MTTest2Class3()
with pytest.raises(RuntimeError):
outdict = dataclass_to_dict(val3)
# And input.
indict3 = {'type': 'm3'}
with pytest.raises(RuntimeError):
val3 = dataclass_from_dict(MTTest2Base, indict3)