diff --git a/README.md b/README.md index dd7dc8bf..ca56b11c 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,24 @@ dump_dict = {"endpoint": "some_api_endpoint", "data": {"foo": 1, "bar": "2"}, "u dump.to_dict() # {"endpoint": "some_api_endpoint", "data": {"foo": 1, "bar": "2"}} ``` -3. You can save them in a catch-all field and do whatever needs to be done later. Simply set the `undefined` +3. You can issue a `RuntimeWarning` for undefined parameters by setting the `undefined` keyword to `Undefined.WARN` + (`WARN` as a case-insensitive string works as well). Note that you will not be able to retrieve them using `to_dict`: + + ```python + from dataclasses_json import Undefined + + @dataclass_json(undefined=Undefined.WARN) + @dataclass() + class WarnAPIDump: + endpoint: str + data: Dict[str, Any] + + dump = WarnAPIDump.from_dict(dump_dict) # WarnAPIDump(endpoint='some_api_endpoint', data={'foo': 1, 'bar': '2'}) + # RuntimeWarning("Received undefined initialization arguments (, {'undefined_field_name': [1, 2, 3]})") + dump.to_dict() # {"endpoint": "some_api_endpoint", "data": {"foo": 1, "bar": "2"}} + ``` + +4. You can save them in a catch-all field and do whatever needs to be done later. Simply set the `undefined` keyword to `Undefined.INCLUDE` (`'INCLUDE'` as a case-insensitive string works as well) and define a field of type `CatchAll` where all unknown values will end up. This simply represents a dictionary that can hold anything. @@ -398,11 +415,13 @@ of type `CatchAll` where all unknown values will end up. - When specifying a default (or a default factory) for the the `CatchAll`-field, e.g. `unknown_things: CatchAll = None`, the default value will be used instead of an empty dict if there are no undefined parameters. - Calling __init__ with non-keyword arguments resolves the arguments to the defined fields and writes everything else into the catch-all field. -4. All 3 options work as well using `schema().loads` and `schema().dumps`, as long as you don't overwrite it by specifying `schema(unknown=)`. +4. The `INCLUDE, EXCLUDE, RAISE` options work as well using `schema().loads` and `schema().dumps`, as long as you don't overwrite it by specifying `schema(unknown=)`. marshmallow uses the same 3 keywords ['include', 'exclude', 'raise'](https://marshmallow.readthedocs.io/en/stable/quickstart.html#handling-unknown-fields). +Marshmallow does not have an equivalent of `WARN`, so when using `schema().loads` we fall back to ignoring undefined parameters. -5. All 3 operations work as well using `__init__`, e.g. `UnknownAPIDump(**dump_dict)` will **not** raise a `TypeError`, but write all unknown values to the field tagged as `CatchAll`. +5. All 4 operations work as well using `__init__`, e.g. `UnknownAPIDump(**dump_dict)` will **not** raise a `TypeError`, but write all unknown values to the field tagged as `CatchAll`. Classes tagged with `EXCLUDE` will also simply ignore unknown parameters. Note that classes tagged as `RAISE` still raise a `TypeError`, and **not** a `UndefinedParameterError` if supplied with unknown keywords. + Classes tagged with `WARN, IGNORE` will resolve keyword arguments before resolving arguments. ### Explanation diff --git a/dataclasses_json/api.py b/dataclasses_json/api.py index 7f36a916..1dbd0d35 100644 --- a/dataclasses_json/api.py +++ b/dataclasses_json/api.py @@ -104,6 +104,9 @@ def schema(cls: Type[A], if unknown is None: undefined_parameter_action = _undefined_parameter_action_safe(cls) if undefined_parameter_action is not None: + if undefined_parameter_action == Undefined.WARN: + # mm has no warn implementation, so we resolve to ignore + undefined_parameter_action = Undefined.EXCLUDE # We can just make use of the same-named mm keywords unknown = undefined_parameter_action.name.lower() diff --git a/dataclasses_json/undefined.py b/dataclasses_json/undefined.py index 96ac284f..031384a9 100644 --- a/dataclasses_json/undefined.py +++ b/dataclasses_json/undefined.py @@ -5,6 +5,7 @@ from dataclasses import Field, fields from typing import Any, Callable, Dict, Optional, Tuple from enum import Enum +import warnings from marshmallow import ValidationError @@ -73,6 +74,64 @@ def handle_from_dict(cls, kvs: Dict) -> Dict[str, Any]: return known +class _WarnUndefinedParameters(_UndefinedParameterAction): + """ + This action issues a RuntimeWarning if it encounters an undefined + parameter during initialization. + The class is then initialized with the known parameters. + """ + + @staticmethod + def handle_from_dict(cls, kvs: Dict[Any, Any]) -> Dict[str, Any]: + known, unknown = \ + _UndefinedParameterAction._separate_defined_undefined_kvs( + cls=cls, kvs=kvs) + if len(unknown) > 0: + msg = f"Received undefined initialization arguments {unknown}" + warnings.warn(msg, category=RuntimeWarning) + return known + + @staticmethod + def create_init(obj) -> Callable: + original_init = obj.__init__ + init_signature = inspect.signature(original_init) + + @functools.wraps(obj.__init__) + def _warn_init(self, *args, **kwargs): + known_kwargs, unknown_kwargs = \ + _CatchAllUndefinedParameters._separate_defined_undefined_kvs( + obj, kwargs) + num_params_takeable = len( + init_signature.parameters) - 1 # don't count self + num_args_takeable = num_params_takeable - len(known_kwargs) + + known_args = args[:num_args_takeable] + unknown_args = args[num_args_takeable:] + bound_parameters = init_signature.bind_partial(self, *known_args, + **known_kwargs) + bound_parameters.apply_defaults() + + arguments = bound_parameters.arguments + arguments.pop("self", None) + final_parameters = \ + _WarnUndefinedParameters.handle_from_dict(obj, arguments) + + if unknown_args or unknown_kwargs: + args_message = "" + if unknown_args: + args_message = f"{unknown_args}" + kwargs_message = "" + if unknown_kwargs: + kwargs_message = f"{unknown_kwargs}" + message = f"Received undefined initialization arguments" \ + f" ({args_message}, {kwargs_message})" + warnings.warn(message=message, category=RuntimeWarning) + + original_init(self, **final_parameters) + + return _warn_init + + CatchAll = Optional[CatchAllVar] @@ -264,6 +323,7 @@ class Undefined(Enum): INCLUDE = _CatchAllUndefinedParameters RAISE = _RaiseUndefinedParameters EXCLUDE = _IgnoreUndefinedParameters + WARN = _WarnUndefinedParameters class UndefinedParameterError(ValidationError): diff --git a/tests/test_undefined_parameters.py b/tests/test_undefined_parameters.py index 58740dcb..9f52806b 100644 --- a/tests/test_undefined_parameters.py +++ b/tests/test_undefined_parameters.py @@ -1,3 +1,4 @@ +import warnings from dataclasses import dataclass, field from typing import Any, Dict, List @@ -5,7 +6,8 @@ import marshmallow from dataclasses_json.core import Json -from dataclasses_json.api import dataclass_json, LetterCase, Undefined, DataClassJsonMixin +from dataclasses_json.api import dataclass_json, LetterCase, Undefined, \ + DataClassJsonMixin from dataclasses_json import CatchAll from dataclasses_json.undefined import UndefinedParameterError @@ -39,6 +41,13 @@ class DontCareAPIDump: data: Dict[str, Any] +@dataclass_json(undefined=Undefined.WARN) +@dataclass +class WarnApiDump: + endpoint: str + data: Dict[str, Any] + + @pytest.fixture def valid_response() -> Dict[Any, Json]: return {"endpoint": "some_api_endpoint", "data": {"foo": 1, "bar": "2"}} @@ -77,310 +86,380 @@ def boss_json(): return boss_json -def test_undefined_parameters_catch_all_invalid_back(invalid_response): - dump = UnknownAPIDump.from_dict(invalid_response) - inverse_dict = dump.to_dict() - assert inverse_dict == invalid_response - - -def test_undefined_parameters_catch_all_valid(valid_response): - dump = UnknownAPIDump.from_dict(valid_response) - assert dump.catch_all == {} - - -def test_undefined_parameters_catch_all_no_field(invalid_response): - with pytest.raises(UndefinedParameterError): - UnknownAPIDumpNoCatchAllField.from_dict(invalid_response) - - -def test_undefined_parameters_catch_all_multiple_fields(invalid_response): - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass() - class UnknownAPIDumpMultipleCatchAll: - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll - catch_all2: CatchAll - - with pytest.raises(UndefinedParameterError): - UnknownAPIDumpMultipleCatchAll.from_dict(invalid_response) - - -def test_undefined_parameters_catch_all_works_with_letter_case(invalid_response_camel_case): - @dataclass_json(undefined=Undefined.INCLUDE, letter_case=LetterCase.CAMEL) - @dataclass() - class UnknownAPIDumpCamelCase: - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll - - dump = UnknownAPIDumpCamelCase.from_dict(invalid_response_camel_case) - assert {"undefinedFieldName": [1, 2, 3]} == dump.catch_all - assert invalid_response_camel_case == dump.to_dict() - - -def test_undefined_parameters_catch_all_raises_if_initialized_with_catch_all_field_name(valid_response): - valid_response["catch_all"] = "some-value" - with pytest.raises(UndefinedParameterError): - UnknownAPIDump.from_dict(valid_response) - - -def test_undefined_parameters_catch_all_initialized_with_dict_and_more_unknown(invalid_response): - invalid_response["catch_all"] = {"someValue": "some-stuff"} - dump = UnknownAPIDump.from_dict(invalid_response) - assert dump.catch_all == {"someValue": "some-stuff", "undefined_field_name": [1, 2, 3]} - - -def test_undefined_parameters_raise_invalid(invalid_response): - with pytest.raises(UndefinedParameterError): - WellKnownAPIDump.from_dict(invalid_response) - - -def test_undefined_parameters_raise_valid(valid_response): - assert valid_response == WellKnownAPIDump.from_dict(valid_response).to_dict() - - -def test_undefined_parameters_ignore(valid_response, invalid_response): - from_valid = DontCareAPIDump.from_dict(valid_response) - from_invalid = DontCareAPIDump.from_dict(invalid_response) - assert from_valid == from_invalid - +class TestCatchAllUndefinedParameters: -def test_undefined_parameters_ignore_to_dict(invalid_response, valid_response): - dump = DontCareAPIDump.from_dict(invalid_response) - dump_dict = dump.to_dict() - assert valid_response == dump_dict + def test_it_dumps_undefined_parameters_back(self, invalid_response): + dump = UnknownAPIDump.from_dict(invalid_response) + inverse_dict = dump.to_dict() + assert inverse_dict == invalid_response + def test_dump_has_no_undefined_parameters_if_not_given(self, + valid_response): + dump = UnknownAPIDump.from_dict(valid_response) + assert dump.catch_all == {} -def test_undefined_parameters_ignore_nested_schema(boss_json): - @dataclass_json(undefined=Undefined.EXCLUDE) - @dataclass(frozen=True) - class Minion: - name: str + def test_it_requires_a_catch_all_field(self, invalid_response): + with pytest.raises(UndefinedParameterError): + UnknownAPIDumpNoCatchAllField.from_dict(invalid_response) - @dataclass_json(undefined=Undefined.EXCLUDE) - @dataclass(frozen=True) - class Boss: - minions: List[Minion] - - boss = Boss.schema().loads(boss_json) - assert len(boss.minions) == 2 - assert boss.minions == [Minion(name="evil minion"), Minion(name="very evil minion")] - - -def test_undefined_parameters_raise_nested_schema(boss_json): - @dataclass_json(undefined=Undefined.RAISE) - @dataclass(frozen=True) - class Minion: - name: str - - @dataclass_json(undefined=Undefined.EXCLUDE) - @dataclass(frozen=True) - class Boss: - minions: List[Minion] - - with pytest.raises(marshmallow.exceptions.ValidationError): - Boss.schema().loads(boss_json) - - -def test_undefined_parameters_catch_all_nested_schema(boss_json): - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass(frozen=True) - class Minion: - name: str - catch_all: CatchAll - - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass(frozen=True) - class Boss: - minions: List[Minion] - catch_all: CatchAll - - boss = Boss.schema().loads(boss_json) - assert {"UNKNOWN_PROPERTY": "value"} == boss.catch_all - assert {"UNKNOWN_PROPERTY": "value"} == boss.minions[0].catch_all - assert {} == boss.minions[1].catch_all - - -def test_undefined_parameters_catch_all_schema_dump(boss_json): - import json + def test_it_requires_exactly_one_catch_all_field(self, invalid_response): + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass() + class UnknownAPIDumpMultipleCatchAll: + endpoint: str + data: Dict[str, Any] + catch_all: CatchAll + catch_all2: CatchAll - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass(frozen=True) - class Minion: - name: str - catch_all: CatchAll + with pytest.raises(UndefinedParameterError): + UnknownAPIDumpMultipleCatchAll.from_dict(invalid_response) - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass(frozen=True) - class Boss: - minions: List[Minion] - catch_all: CatchAll + def test_it_works_with_letter_case(self, invalid_response_camel_case): + @dataclass_json(undefined=Undefined.INCLUDE, + letter_case=LetterCase.CAMEL) + @dataclass() + class UnknownAPIDumpCamelCase: + endpoint: str + data: Dict[str, Any] + catch_all: CatchAll + + dump = UnknownAPIDumpCamelCase.from_dict(invalid_response_camel_case) + assert {"undefinedFieldName": [1, 2, 3]} == dump.catch_all + assert invalid_response_camel_case == dump.to_dict() + + def test_catch_all_field_name_cant_be_a_primitive_parameter(self, + valid_response): + valid_response["catch_all"] = "some-value" + with pytest.raises(UndefinedParameterError): + UnknownAPIDump.from_dict(valid_response) + + def test_catch_all_field_can_be_initialized_with_dict(self, + invalid_response): + invalid_response["catch_all"] = {"someValue": "some-stuff"} + dump = UnknownAPIDump.from_dict(invalid_response) + assert dump.catch_all == {"someValue": "some-stuff", + "undefined_field_name": [1, 2, 3]} + + def test_it_raises_with_default_argument_and_catch_all_field_name(self, + invalid_response): + @dataclass_json(undefined="include") + @dataclass() + class UnknownAPIDumpDefault: + endpoint: str + data: Dict[str, Any] + catch_all: CatchAll = None - boss = Boss.schema().loads(boss_json) - assert json.loads(boss_json) == Boss.schema().dump(boss) - assert "".join(boss_json.replace('\n', '').split()) == "".join(Boss.schema().dumps(boss).replace('\n', '').split()) + invalid_response["catch_all"] = "this should not happen" + with pytest.raises(UndefinedParameterError): + UnknownAPIDumpDefault.from_dict(invalid_response) + def test_catch_all_field_can_have_default(self, valid_response, + invalid_response): + @dataclass_json(undefined="include") + @dataclass() + class UnknownAPIDumpDefault: + endpoint: str + data: Dict[str, Any] + catch_all: CatchAll = None -def test_undefined_parameters_catch_all_schema_roundtrip(boss_json): - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass(frozen=True) - class Minion: - name: str - catch_all: CatchAll + from_valid = UnknownAPIDumpDefault.from_dict(valid_response) + from_invalid = UnknownAPIDumpDefault.from_dict(invalid_response) + assert from_valid.catch_all is None + assert {"undefined_field_name": [1, 2, 3]} == from_invalid.catch_all - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass(frozen=True) - class Boss: - minions: List[Minion] - catch_all: CatchAll + def test_catch_all_field_can_have_default_factory(self, valid_response, + invalid_response): + @dataclass_json(undefined="include") + @dataclass() + class UnknownAPIDumpDefault(DataClassJsonMixin): + endpoint: str + data: Dict[str, Any] + catch_all: CatchAll = field(default_factory=dict) + + from_valid = UnknownAPIDumpDefault.from_dict(valid_response) + from_invalid = UnknownAPIDumpDefault.from_dict(invalid_response) + assert from_valid.catch_all == {} + assert {"undefined_field_name": [1, 2, 3]} == from_invalid.catch_all + + def test_it_works_with_nested_schemata(self, boss_json): + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass(frozen=True) + class Minion: + name: str + catch_all: CatchAll + + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass(frozen=True) + class Boss: + minions: List[Minion] + catch_all: CatchAll + + boss = Boss.schema().loads(boss_json) + assert {"UNKNOWN_PROPERTY": "value"} == boss.catch_all + assert {"UNKNOWN_PROPERTY": "value"} == boss.minions[0].catch_all + assert {} == boss.minions[1].catch_all + + def test_it_dumps_nested_schemata_correctly(self, boss_json): + import json + + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass(frozen=True) + class Minion: + name: str + catch_all: CatchAll + + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass(frozen=True) + class Boss: + minions: List[Minion] + catch_all: CatchAll + + boss = Boss.schema().loads(boss_json) + assert json.loads(boss_json) == Boss.schema().dump(boss) + assert "".join(boss_json.replace('\n', '').split()) == "".join( + Boss.schema().dumps(boss).replace('\n', '').split()) + + def test_it_preserves_nested_schemata_in_roundtrip(self, boss_json): + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass(frozen=True) + class Minion: + name: str + catch_all: CatchAll + + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass(frozen=True) + class Boss: + minions: List[Minion] + catch_all: CatchAll + + boss1 = Boss.schema().loads(boss_json) + dumped_s = Boss.schema().dumps(boss1) + boss2 = Boss.schema().loads(dumped_s) + assert boss1 == boss2 + + def test_it_works_from_string(self, invalid_response): + @dataclass_json(undefined="include") + @dataclass() + class UnknownAPIDumpFromString: + endpoint: str + data: Dict[str, Any] + catch_all: CatchAll + + dump = UnknownAPIDumpFromString.from_dict(invalid_response) + assert {"undefined_field_name": [1, 2, 3]} == dump.catch_all + + def test_it_works_with_valid_dict_expansion(self, valid_response): + dump = UnknownAPIDump(**valid_response) + assert dump.catch_all == {} + + def test_it_works_with_invalid_dict_expansion(self, invalid_response): + dump = UnknownAPIDump(**invalid_response) + assert {"undefined_field_name": [1, 2, 3]} == dump.catch_all + + def test_it_creates_dummy_keys_for_init_args(self): + dump = UnknownAPIDump("some-endpoint", {"some-data": "foo"}, "unknown1", + "unknown2", undefined="123") + assert dump.endpoint == "some-endpoint" + assert dump.data == {"some-data": "foo"} + assert dump.catch_all == {'_UNKNOWN0': 'unknown1', + '_UNKNOWN1': 'unknown2', + "undefined": "123"} + + def test_it_creates_dummy_keys_for_init_args_kwargs_mix(self): + dump = UnknownAPIDump("some-endpoint", {"some-data": "foo"}, "unknown1", + "unknown2", catch_all={"bar": "example"}, + undefined="123") + assert dump.endpoint == "some-endpoint" + assert dump.data == {"some-data": "foo"} + assert dump.catch_all == {'_UNKNOWN0': 'unknown1', + '_UNKNOWN1': 'unknown2', + "bar": "example", "undefined": "123"} + + def test_it_doesnt_dump_the_default_value_without_undefined_parameters( + self, + valid_response): + @dataclass_json(undefined="include") + @dataclass() + class UnknownAPIDumpDefault: + endpoint: str + data: Dict[str, Any] + catch_all: CatchAll = None - boss1 = Boss.schema().loads(boss_json) - dumped_s = Boss.schema().dumps(boss1) - boss2 = Boss.schema().loads(dumped_s) - assert boss1 == boss2 + dump = UnknownAPIDumpDefault.from_dict(valid_response) + assert dump.to_dict() == valid_response - -def test_undefined_parameters_catch_all_ignore_mix_nested_schema(boss_json): - @dataclass_json(undefined=Undefined.EXCLUDE) - @dataclass(frozen=True) - class Minion: - name: str - - @dataclass_json(undefined=Undefined.INCLUDE) - @dataclass(frozen=True) - class Boss: - minions: List[Minion] - catch_all: CatchAll - - boss = Boss.schema().loads(boss_json) - assert Minion(name="evil minion") == boss.minions[0] - assert Minion(name="very evil minion") == boss.minions[1] - assert {"UNKNOWN_PROPERTY": "value"} == boss.catch_all - - -def test_it_works_from_string(invalid_response): - @dataclass_json(undefined="include") - @dataclass() - class UnknownAPIDumpFromString: - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll - - dump = UnknownAPIDumpFromString.from_dict(invalid_response) - assert {"undefined_field_name": [1, 2, 3]} == dump.catch_all - - -def test_string_only_accepts_valid_actions(): - with pytest.raises(UndefinedParameterError): - @dataclass_json(undefined="not sure what this is supposed to do") + def test_it_dumps_default_factory_without_undefined_parameters(self, + valid_response): + @dataclass_json(undefined="include") @dataclass() - class WontWork: + class UnknownAPIDumpDefault: endpoint: str + data: Dict[str, Any] + catch_all: CatchAll = field(default_factory=dict) + + dump = UnknownAPIDumpDefault(**valid_response) + assert dump.catch_all == {} -def test_undefined_parameters_raises_with_default_argument_and_supplied_catch_all_name(invalid_response): - @dataclass_json(undefined="include") - @dataclass() - class UnknownAPIDumpDefault: - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll = None +class TestRaiseUndefinedParameters: - invalid_response["catch_all"] = "this should not happen" - with pytest.raises(UndefinedParameterError): - UnknownAPIDumpDefault.from_dict(invalid_response) + def test_it_raises_with_undefined_parameters(self, invalid_response): + with pytest.raises(UndefinedParameterError): + WellKnownAPIDump.from_dict(invalid_response) + def test_it_doesnt_raise_with_known_parameters(self, valid_response): + assert valid_response == WellKnownAPIDump.from_dict( + valid_response).to_dict() -def test_undefined_parameters_doesnt_raise_with_default(valid_response, invalid_response): - @dataclass_json(undefined="include") - @dataclass() - class UnknownAPIDumpDefault: - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll = None + def test_it_has_python_semantics_in_init(self, invalid_response): + with pytest.raises(TypeError): + WellKnownAPIDump(**invalid_response) - from_valid = UnknownAPIDumpDefault.from_dict(valid_response) - from_invalid = UnknownAPIDumpDefault.from_dict(invalid_response) - assert from_valid.catch_all is None - assert {"undefined_field_name": [1, 2, 3]} == from_invalid.catch_all +class TestWarnUndefinedParameters: -def test_undefined_parameters_doesnt_raise_with_default_factory(valid_response, invalid_response): - @dataclass_json(undefined="include") - @dataclass() - class UnknownAPIDumpDefault(DataClassJsonMixin): - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll = field(default_factory=dict) + def test_it_raises_with_undefined_parameters(self, invalid_response): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") - from_valid = UnknownAPIDumpDefault.from_dict(valid_response) - from_invalid = UnknownAPIDumpDefault.from_dict(invalid_response) - assert from_valid.catch_all == {} - assert {"undefined_field_name": [1, 2, 3]} == from_invalid.catch_all + WarnApiDump.from_dict(invalid_response) + assert len(w) == 1 + assert issubclass(w[-1].category, RuntimeWarning) + expected_message = "Received undefined initialization arguments " \ + "{'undefined_field_name': [1, 2, 3]}" + assert expected_message == w[-1].message.args[0] -def test_undefined_parameters_catch_all_init_valid(valid_response): - dump = UnknownAPIDump(**valid_response) - assert dump.catch_all == {} + def test_it_doesnt_raise_with_known_parameters(self, valid_response): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + roundtrip = WarnApiDump.from_dict(valid_response).to_dict() -def test_undefined_parameters_catch_all_init_invalid(invalid_response): - dump = UnknownAPIDump(**invalid_response) - assert {"undefined_field_name": [1, 2, 3]} == dump.catch_all + assert valid_response == roundtrip + assert len(w) == 0 + def test_it_warns_in_init_kwargs(self, invalid_response): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") -def test_undefined_parameters_catch_all_init_args(): - dump = UnknownAPIDump("some-endpoint", {"some-data": "foo"}, "unknown1", "unknown2", undefined="123") - assert dump.endpoint == "some-endpoint" - assert dump.data == {"some-data": "foo"} - assert dump.catch_all == {'_UNKNOWN0': 'unknown1', '_UNKNOWN1': 'unknown2', "undefined": "123"} + WarnApiDump(**invalid_response) + assert len(w) == 1 + assert issubclass(w[-1].category, RuntimeWarning) + expected_message = "Received undefined initialization arguments " \ + "(, {'undefined_field_name': [1, 2, 3]})" + assert w[-1].message.args[0] == expected_message -def test_undefined_parameters_catch_all_init_args_kwargs_mixed(): - dump = UnknownAPIDump("some-endpoint", {"some-data": "foo"}, "unknown1", "unknown2", catch_all={"bar": "example"}, - undefined="123") - assert dump.endpoint == "some-endpoint" - assert dump.data == {"some-data": "foo"} - assert dump.catch_all == {'_UNKNOWN0': 'unknown1', '_UNKNOWN1': 'unknown2', "bar": "example", "undefined": "123"} + def test_it_warns_init_args_kwargs_mixed_preferring_kwargs(self, + invalid_response): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + dump = WarnApiDump("some-arg", **invalid_response) -def test_undefined_parameters_ignore_init_args(): - dump = DontCareAPIDump("some-endpoint", {"some-data": "foo"}, "unknown1", "unknown2", undefined="123") - assert dump.endpoint == "some-endpoint" - assert dump.data == {"some-data": "foo"} + assert dump.endpoint == invalid_response["endpoint"] + assert dump.data == invalid_response["data"] + assert len(w) == 1 + assert issubclass(w[-1].category, RuntimeWarning) + expected_message = "Received undefined initialization arguments " \ + "(('some-arg',), {'undefined_field_name': [1, 2, 3]})" + assert w[-1].message.args[0] == expected_message + def test_it_ignores_when_using_schema(self, invalid_response): + import json -def test_undefined_parameters_ignore_init_invalid(invalid_response, valid_response): - dump_invalid = DontCareAPIDump(**invalid_response) - dump_valid = DontCareAPIDump(**valid_response) - assert dump_valid == dump_invalid + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + dump = WarnApiDump.schema().loads(json.dumps(invalid_response)) -def test_undefined_parameters_raise_init(invalid_response): - with pytest.raises(TypeError): - WellKnownAPIDump(**invalid_response) + assert len(w) == 0 + assert dump.endpoint == invalid_response["endpoint"] + assert dump.data == invalid_response["data"] -def test_undefined_parameters_catch_all_default_no_undefined(valid_response): - @dataclass_json(undefined="include") - @dataclass() - class UnknownAPIDumpDefault: - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll = None +class TestIgnoreUndefinedParameters: - dump = UnknownAPIDumpDefault.from_dict(valid_response) - assert dump.to_dict() == valid_response + def test_it_ignores_undefined_parameters(self, valid_response, + invalid_response): + from_valid = DontCareAPIDump.from_dict(valid_response) + from_invalid = DontCareAPIDump.from_dict(invalid_response) + assert from_valid == from_invalid + def test_it_does_not_dump_undefined_parameters(self, invalid_response, + valid_response): + dump = DontCareAPIDump.from_dict(invalid_response) + dump_dict = dump.to_dict() + assert valid_response == dump_dict -def test_undefined_parameters_catch_all_default_factory_init_converts_factory(valid_response): - @dataclass_json(undefined="include") - @dataclass() - class UnknownAPIDumpDefault: - endpoint: str - data: Dict[str, Any] - catch_all: CatchAll = field(default_factory=dict) + def test_it_ignores_nested_schemata(self, boss_json): + @dataclass_json(undefined=Undefined.EXCLUDE) + @dataclass(frozen=True) + class Minion: + name: str - dump = UnknownAPIDumpDefault(**valid_response) - assert dump.catch_all == {} \ No newline at end of file + @dataclass_json(undefined=Undefined.EXCLUDE) + @dataclass(frozen=True) + class Boss: + minions: List[Minion] + + boss = Boss.schema().loads(boss_json) + assert len(boss.minions) == 2 + assert boss.minions == [Minion(name="evil minion"), + Minion(name="very evil minion")] + + def test_it_ignores_undefined_init_args(self): + dump = DontCareAPIDump("some-endpoint", {"some-data": "foo"}, + "unknown1", + "unknown2", undefined="123") + assert dump.endpoint == "some-endpoint" + assert dump.data == {"some-data": "foo"} + + def test_it_ignores_undefined_init_kwargs(self, invalid_response, + valid_response): + dump_invalid = DontCareAPIDump(**invalid_response) + dump_valid = DontCareAPIDump(**valid_response) + assert dump_valid == dump_invalid + + +class TestMiscellaneousUndefinedParameters: + + def test_it_raises_with_nested_schemata(self, boss_json): + @dataclass_json(undefined=Undefined.RAISE) + @dataclass(frozen=True) + class Minion: + name: str + + @dataclass_json(undefined=Undefined.EXCLUDE) + @dataclass(frozen=True) + class Boss: + minions: List[Minion] + + with pytest.raises(marshmallow.exceptions.ValidationError): + Boss.schema().loads(boss_json) + + def test_it_works_with_catch_all_ignore_mix_nested_schemata(self, + boss_json): + @dataclass_json(undefined=Undefined.EXCLUDE) + @dataclass(frozen=True) + class Minion: + name: str + + @dataclass_json(undefined=Undefined.INCLUDE) + @dataclass(frozen=True) + class Boss: + minions: List[Minion] + catch_all: CatchAll + + boss = Boss.schema().loads(boss_json) + assert Minion(name="evil minion") == boss.minions[0] + assert Minion(name="very evil minion") == boss.minions[1] + assert {"UNKNOWN_PROPERTY": "value"} == boss.catch_all + + def test_it_only_acceps_valid_actions_as_string(self): + with pytest.raises(UndefinedParameterError): + @dataclass_json(undefined="not sure what this is supposed to do") + @dataclass() + class WontWork: + endpoint: str