Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds a configuration option to allow mixed alias/unaliased field name use when deserializing #175

Merged
merged 8 commits into from
Nov 14, 2023
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Table of contents
* [`discriminator` config option](#discriminator-config-option)
* [`lazy_compilation` config option](#lazy_compilation-config-option)
* [`sort_keys` config option](#sort_keys-config-option)
* [`allow_deserialization_not_by_alias` config option](#allow_deserialization_not_by_alias-config-option)
* [Passing field values as is](#passing-field-values-as-is)
* [Extending existing types](#extending-existing-types)
* [Dialects](#dialects)
Expand Down Expand Up @@ -1391,6 +1392,38 @@ t = SortedDataClass(1, 2)
assert t.to_dict() == {"bar": 2, "foo": 1}
```

#### `allow_deserialization_not_by_alias` config option

When using aliases, the deserializer defaults to requiring the keys to match
what is defined as the alias in the metadata.
If the flexibility to deserialize aliased and unaliased keys is required then
the config option `allow_deserialization_not_by_alias = True` can be set to
enable the feature.

```python
from dataclasses import dataclass, field
from mashumaro import DataClassDictMixin
from mashumaro.config import BaseConfig, TO_DICT_ADD_BY_ALIAS_FLAG


@dataclass
class AliasedDataClass(DataClassDictMixin):
foo: int = field(metadata={"alias": "alias_foo"})
bar: int = field(metadata={"alias": "alias_bar"})

class Config(BaseConfig):
serialize_by_alias = True
allow_deserialization_not_by_alias = True
code_generation_options = [TO_DICT_ADD_BY_ALIAS_FLAG]


no_alias_dict = {"bar": 2, "foo": 1}
# Will raise `mashumaro.exceptions.MissingField` if allow_deserialization_not_by_alias is
# False
t = AliasedDataClass.from_dict(no_alias_dict)
assert t.to_dict(by_alias=False) == {"bar": 2, "foo": 1}
```

### Passing field values as is

In some cases it's needed to pass a field value as is without any changes
Expand Down
1 change: 1 addition & 0 deletions mashumaro/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ class BaseConfig:
discriminator: Optional[Discriminator] = None
lazy_compilation: bool = False
sort_keys: bool = False
allow_deserialization_not_by_alias: bool = False
38 changes: 29 additions & 9 deletions mashumaro/core/meta/code/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,16 +555,36 @@ def _unpack_method_set_value(
could_be_none=False if could_be_none else True,
)
)
if unpacked_value != "value":
self.add_line(f"value = d.get('{alias or fname}', MISSING)")
packed_value = "value"
elif has_default:
self.add_line(f"value = d.get('{alias or fname}', MISSING)")
packed_value = "value"
if self.get_config().allow_deserialization_not_by_alias:
if unpacked_value != "value":
self.add_line(f"value = d.get('{alias}', MISSING)")
with self.indent("if value is MISSING:"):
self.add_line(f"value = d.get('{fname}', MISSING)")
packed_value = "value"
elif has_default:
self.add_line(f"value = d.get('{alias}', MISSING)")
with self.indent("if value is MISSING:"):
self.add_line(f"value = d.get('{fname}', MISSING)")
packed_value = "value"
else:
self.add_line(f"__{fname} = d.get('{alias}', MISSING)")
with self.indent(f"if __{fname} is MISSING:"):
self.add_line(f"__{fname} = d.get('{fname}', MISSING)")
packed_value = f"__{fname}"
unpacked_value = packed_value
else:
self.add_line(f"__{fname} = d.get('{alias or fname}', MISSING)")
packed_value = f"__{fname}"
unpacked_value = packed_value
if unpacked_value != "value":
self.add_line(f"value = d.get('{alias or fname}', MISSING)")
packed_value = "value"
elif has_default:
self.add_line(f"value = d.get('{alias or fname}', MISSING)")
packed_value = "value"
else:
self.add_line(
f"__{fname} = d.get('{alias or fname}', MISSING)"
)
packed_value = f"__{fname}"
unpacked_value = packed_value
if not has_default:
with self.indent(f"if {packed_value} is MISSING:"):
self.add_line(
Expand Down
22 changes: 22 additions & 0 deletions tests/test_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,25 @@ class Config(BaseConfig):

assert DataClass(x=123).to_dict() == {"alias": 123}
assert DataClass(x=None).to_dict() == {"alias": None}


def test_by_field_with_loose_deserialize():
@dataclass
class DataClass(DataClassDictMixin):
a: int = field(metadata={"alias": "alias_a"})
b: Optional[int] = field(metadata={"alias": "alias_b"})
Fatal1ty marked this conversation as resolved.
Show resolved Hide resolved
c: Optional[str] = field(metadata={"alias": "alias_c"}, default="789")

class Config(BaseConfig):
serialize_by_alias = True
code_generation_options = [TO_DICT_ADD_BY_ALIAS_FLAG]
allow_deserialization_not_by_alias = True

instance = DataClass(a=123, b=456)
assert DataClass.from_dict({"a": 123, "alias_b": 456}) == instance
assert instance.to_dict() == {
"alias_a": 123,
"alias_b": 456,
"alias_c": "789",
}
assert instance.to_dict(by_alias=False) == {"a": 123, "b": 456, "c": "789"}
25 changes: 24 additions & 1 deletion tests/test_data_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
from typing_extensions import Final, LiteralString

from mashumaro import DataClassDictMixin
from mashumaro.config import BaseConfig
from mashumaro.config import BaseConfig, TO_DICT_ADD_BY_ALIAS_FLAG
from mashumaro.core.const import PEP_585_COMPATIBLE, PY_39_MIN
from mashumaro.exceptions import (
InvalidFieldValue,
Expand Down Expand Up @@ -517,6 +517,29 @@ class DataClass(DataClassDictMixin):
assert same_types(instance_loaded.x, x_value)


@pytest.mark.parametrize("value_info", inner_values)
@pytest.mark.parametrize("use_alias", [False, True])
def test_level_one_with_aliased_unaliased_fields(value_info, use_alias):
x_type, x_value, x_value_dumped = value_info

@dataclass
class DataClass(DataClassDictMixin):
x: x_type = field(metadata={"alias": "alias_x"})

class Config(BaseConfig):
allow_deserialization_not_by_alias = True
code_generation_options = [TO_DICT_ADD_BY_ALIAS_FLAG]

instance = DataClass(x_value)
dumped = {"alias_x" if use_alias else "x": x_value_dumped}
instance_dumped = instance.to_dict(by_alias=use_alias)
instance_loaded = DataClass.from_dict(dumped)
assert instance_dumped == dumped
assert instance_loaded == instance
assert same_types(instance_dumped, dumped)
assert same_types(instance_loaded.x, x_value)


@pytest.mark.parametrize("value_info", inner_values)
def test_with_generic_list(value_info):
check_collection_generic(List, value_info)
Expand Down