ballistica/tests/test_efro/test_dataclassio.py

1069 lines
31 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""Testing dataclasses functionality."""
# pylint: disable=too-many-lines
from __future__ import annotations
from enum import Enum
import datetime
from dataclasses import field, dataclass
from typing import TYPE_CHECKING, Any, Sequence, Annotated
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,
)
if TYPE_CHECKING:
pass
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)
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
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()
# A dict containing *ALL* values should 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,
],
}
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_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]
@classmethod
def will_input(cls, data: dict) -> None:
data['vals'] = data['vals'][:2]
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