예제 #1
0
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
예제 #2
0
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 _hosting_state_response(self, result: Optional[dict[str,
                                                            Any]]) -> None:

        # Its possible for this to come back to us after our UI is dead;
        # ignore in that case.
        if not self._container:
            return

        state: Optional[PrivateHostingState] = None
        if result is not None:
            self._debug_server_comm('got private party state response')
            try:
                state = dataclass_from_dict(PrivateHostingState,
                                            result,
                                            discard_unknown_attrs=True)
            except Exception:
                ba.print_exception('Got invalid PrivateHostingState data')
        else:
            self._debug_server_comm('private party state response errored')

        # Hmm I guess let's just ignore failed responses?...
        # Or should we show some sort of error state to the user?...
        if result is None or state is None:
            return

        self._waiting_for_initial_state = False
        self._waiting_for_start_stop_response = False
        self._hostingstate = state
        self._refresh_sub_tab()
예제 #4
0
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]}
예제 #5
0
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
예제 #6
0
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)
예제 #7
0
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
예제 #8
0
    def _load_config_from_file(self, print_confirmation: bool) -> ServerConfig:

        out: Optional[ServerConfig] = None

        if not os.path.exists(self._config_path):

            # Special case:
            # If the user didn't specify a particular config file, allow
            # gracefully falling back to defaults if the default one is
            # missing.
            if not self._user_provided_config_path:
                if print_confirmation:
                    print(
                        f'{Clr.YLW}Default config file not found'
                        f' (\'{self._config_path}\'); using default'
                        f' settings.{Clr.RST}',
                        flush=True)
                self._config_mtime = None
                self._last_config_mtime_check_time = time.time()
                return ServerConfig()

            # Don't be so lenient if the user pointed us at one though.
            raise RuntimeError(
                f"Config file not found: '{self._config_path}'.")

        import yaml
        with open(self._config_path) as infile:
            user_config_raw = yaml.safe_load(infile.read())

        # An empty config file will yield None, and that's ok.
        if user_config_raw is not None:
            out = dataclass_from_dict(ServerConfig, user_config_raw)

        # Update our known mod-time since we know it exists.
        self._config_mtime = Path(self._config_path).stat().st_mtime
        self._last_config_mtime_check_time = time.time()

        # Go with defaults if we weren't able to load anything.
        if out is None:
            out = ServerConfig()

        if print_confirmation:
            print(f'{Clr.CYN}Valid server config file loaded.{Clr.RST}',
                  flush=True)
        return out
예제 #9
0
    def _from_dict(self, data: dict, types_by_id: dict[int, type[Any]],
                   opname: str) -> Any:
        """Decode a message from a json string."""
        msgdict: Optional[dict]

        m_id = data.get('t')
        # Allow omitting 'm' dict if its empty.
        msgdict = data.get('m', {})

        assert isinstance(m_id, int)
        assert isinstance(msgdict, dict)

        # Decode this particular type.
        msgtype = types_by_id.get(m_id)
        if msgtype is None:
            raise UnregisteredMessageIDError(
                f'Got unregistered {opname} id of {m_id}.')
        return dataclass_from_dict(msgtype, msgdict)
 def _connect_response(self, result: Optional[dict[str, Any]]) -> None:
     try:
         self._connect_press_time = None
         if result is None:
             raise RuntimeError()
         cresult = dataclass_from_dict(PrivatePartyConnectResult,
                                       result,
                                       discard_unknown_attrs=True)
         if cresult.error is not None:
             self._debug_server_comm('got error connect response')
             ba.screenmessage(
                 ba.Lstr(translate=('serverResponses', cresult.error)),
                 (1, 0, 0))
             ba.playsound(ba.getsound('error'))
             return
         self._debug_server_comm('got valid connect response')
         assert cresult.addr is not None and cresult.port is not None
         _ba.connect_to_party(cresult.addr, port=cresult.port)
     except Exception:
         self._debug_server_comm('got connect response error')
         ba.playsound(ba.getsound('error'))
예제 #11
0
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
예제 #12
0
    def _decode(self, data: str, types_by_id: dict[int, type[Any]],
                opname: str) -> Any:
        """Decode a message from a json string."""
        msgfull = json.loads(data)
        assert isinstance(msgfull, dict)
        msgdict: Optional[dict]
        if self._type_key is not None:
            m_id = msgfull.pop(self._type_key)
            msgdict = msgfull
            assert isinstance(m_id, int)
        else:
            m_id = msgfull.get('t')
            msgdict = msgfull.get('m')
        assert isinstance(m_id, int)
        assert isinstance(msgdict, dict)

        # Decode this particular type.
        msgtype = types_by_id.get(m_id)
        if msgtype is None:
            raise UnregisteredMessageIDError(
                f'Got unregistered {opname} id of {m_id}.')
        out = dataclass_from_dict(msgtype, msgdict)

        # Special case: if we get EmptyResponse, we simply return None.
        if isinstance(out, EmptyResponse):
            return None

        # Special case: a remote error occurred. Raise a local Exception
        # instead of returning the message.
        if isinstance(out, ErrorResponse):
            assert opname == 'response'
            if (self.preserve_clean_errors
                    and out.error_type is ErrorType.CLEAN):
                raise CleanError(out.error_message)
            raise RemoteError(out.error_message)

        return out
예제 #13
0
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: Optional[int] = None
        osval: Optional[str] = None
        obval: Optional[bool] = None
        ofval: Optional[float] = None
        oenval: Optional[_EnumTest] = _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: Optional[datetime.datetime] = 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,
        '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,
                'osval': None,
                'obval': None,
                'ofval': None,
                'lsval': [],
                'lival': [],
                'lbval': [],
                'lfval': [],
                'ssval': []
            }), _TestClass)
    assert isinstance(
        dataclass_from_dict(
            _TestClass, {
                'oival': 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, {'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(TypeError):
        dataclass_from_dict(_TestClass, {'tupleval': []})
    with pytest.raises(TypeError):
        dataclass_from_dict(_TestClass, {'tupleval': [1, 1, 1]})
    with pytest.raises(TypeError):
        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()))
예제 #14
0
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)
예제 #15
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[Optional[str], IOAttrs(soft_default=None)]

    @ioprepped
    @dataclass
    class _TestClassE5:
        lval: Annotated[Optional[str], 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[Optional[bool], 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