Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,16 @@ class DataClass(DataClassDictMixin):
x = DataClass.from_dict({"FieldA": 1, "#invalid": 2}) # DataClass(a=1, b=2)
```

A sequence of names assigns [multiple aliases](#field-aliases) to a field. They
are tried in order on deserialization, and the first one is used on
serialization:

```python
@dataclass
class DataClass(DataClassDictMixin):
a: int = field(metadata=field_options(alias=["FieldA", "field_a"]))
```

### Config options

If inheritance is not an empty word for you, you'll fall in love with the
Expand Down Expand Up @@ -1463,6 +1473,17 @@ class DataClass(DataClassDictMixin):
DataClass.from_dict({"FieldA": 1, "FieldB": 2}) # DataClass(a=1, b=2)
```

A value may be a sequence of names to assign [multiple aliases](#field-aliases)
to a field, tried in order on deserialization:

```python
class Config(BaseConfig):
aliases = {
"a": ["FieldA", "field_a"],
"b": "FieldB",
}
```

#### `serialize_by_alias` config option

All the fields with [aliases](#field-aliases) will be serialized by them by
Expand Down Expand Up @@ -1969,6 +1990,32 @@ class DataClass:
> there is [a config option](#allow_deserialization_not_by_alias-config-option)
> for that.

A field may also have more than one alias. Pass a sequence of names instead of
a single string, or use several `Alias(...)` annotations. On deserialization
the aliases are tried in order; the first one present in the input is used.
Serialization uses the first (primary) alias.

```python
from dataclasses import dataclass, field
from typing import Annotated
from mashumaro import DataClassDictMixin, field_options
from mashumaro.config import BaseConfig
from mashumaro.types import Alias

@dataclass
class DataClass(DataClassDictMixin):
# any of these three forms accepts multiple aliases
a: Annotated[int, Alias("id"), Alias("ID")]
b: int = field(metadata=field_options(alias=["b", "bee"]))
c: int = 0

class Config(BaseConfig):
aliases = {"c": ["c", "cee"]}

DataClass.from_dict({"id": 1, "b": 2, "cee": 3}) # DataClass(a=1, b=2, c=3)
DataClass.from_dict({"ID": 1, "bee": 2}) # DataClass(a=1, b=2, c=0)
```

### Dialects

Sometimes it's needed to have different serialization and deserialization
Expand Down
4 changes: 2 additions & 2 deletions mashumaro/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from collections.abc import Callable
from collections.abc import Callable, Sequence
from typing import Any, Literal, Type, TypedDict

from mashumaro.core.const import Sentinel
Expand Down Expand Up @@ -43,7 +43,7 @@ class BaseConfig:
debug: bool = False
code_generation_options: list[CodeGenerationOption] = []
serialization_strategy: dict[Any, SerializationStrategyValueType] = {}
aliases: dict[str, str] = {}
aliases: dict[str, str | Sequence[str]] = {}
serialize_by_alias: bool | Literal[Sentinel.MISSING] = Sentinel.MISSING
namedtuple_as_dict: bool | Literal[Sentinel.MISSING] = Sentinel.MISSING
allow_postponed_evaluation: bool = True
Expand Down
104 changes: 57 additions & 47 deletions mashumaro/core/meta/code/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,12 +432,16 @@ def _add_unpack_method_lines(self, method_name: str) -> None:
kw_only_fields.add(fname)

metadata = self.metadatas.get(fname, {})
alias = self.__get_field_alias(fname, ftype, metadata, config)
aliases = self.__get_field_aliases(
fname, ftype, metadata, config
)

filtered_fields.append((fname, alias, ftype))
filtered_fields.append((fname, aliases, ftype))
if filtered_fields:
if config.forbid_extra_keys:
allowed_keys = {f[1] or f[0] for f in filtered_fields}
allowed_keys = set()
for f in filtered_fields:
allowed_keys |= set(f[1]) if f[1] else {f[0]}

# If a discriminator with a field is set via config,
# we should allow this field to be present in the input
Expand All @@ -462,7 +466,7 @@ def _add_unpack_method_lines(self, method_name: str) -> None:
)

with self.indent("try:"):
for fname, alias, ftype in filtered_fields:
for fname, aliases, ftype in filtered_fields:
self.add_type_modules(ftype)
metadata = self.metadatas.get(fname, {})
field_block = FieldUnpackerCodeBlockBuilder(
Expand All @@ -471,7 +475,7 @@ def _add_unpack_method_lines(self, method_name: str) -> None:
fname=fname,
ftype=ftype,
metadata=metadata,
alias=alias,
aliases=aliases,
)
if field_block.in_kwargs:
add_kwargs = True
Expand Down Expand Up @@ -1137,7 +1141,9 @@ def _get_field_packer(
force_value: bool = False,
) -> typing.Tuple[str, str | None, bool]:
metadata = self.metadatas.get(fname, {})
alias = self.__get_field_alias(fname, ftype, metadata, config)
aliases = self.__get_field_aliases(fname, ftype, metadata, config)
# Serialization writes to the first (primary) alias.
alias = aliases[0] if aliases else None
could_be_none = (
ftype in (typing.Any, type(None), None)
or is_type_var_any(self.get_real_type(fname, ftype))
Expand All @@ -1160,21 +1166,28 @@ def _get_field_packer(
return packer, alias, could_be_none

@staticmethod
def __get_field_alias(
def __get_field_aliases(
fname: str,
ftype: typing.Type,
metadata: typing.Mapping[str, typing.Any],
config: typing.Type[BaseConfig],
) -> str | None:
) -> tuple[str, ...]:
alias = metadata.get("alias")
if alias is None and is_annotated(ftype):
annotations = get_type_annotations(ftype)
for ann in annotations:
if isinstance(ann, Alias):
alias = ann.name
names = [
ann.name
for ann in get_type_annotations(ftype)
if isinstance(ann, Alias)
]
if names:
alias = names
if alias is None:
alias = config.aliases.get(fname)
return alias
if alias is None:
return ()
if isinstance(alias, str):
return (alias,)
return tuple(alias)

@typing.no_type_check
def iter_serialization_strategies(
Expand Down Expand Up @@ -1302,7 +1315,7 @@ def build(
ftype: typing.Type,
metadata: typing.Mapping,
*,
alias: str | None = None,
aliases: tuple[str, ...] = (),
) -> FieldUnpackerCodeBlock:
default = self.parent.get_field_default(fname)
has_default = default is not MISSING
Expand All @@ -1329,47 +1342,44 @@ def build(
could_be_none=False if could_be_none else True,
)
)
# Keys to try, in order: each alias, then the field name itself when
# there are no aliases or deserialization not by alias is allowed.
if aliases:
keys = list(aliases)
if self.parent.get_config().allow_deserialization_not_by_alias:
keys.append(fname)
else:
keys = [fname]
# Drop duplicates (for example the field name repeated as an alias)
# while preserving order, so we never emit a redundant lookup.
keys = list(dict.fromkeys(keys))
if unpacked_value != "value" or has_default:
packed_value = "value"
else:
packed_value = f"__{fname}"
unpacked_value = packed_value
fetched_via_subscript = False
if self.parent.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
elif not has_default:
# Required field: fetch via subscript. On the happy path this is
# noticeably faster than d.get(key, MISSING) followed by an
# identity check, and the field is present the vast majority of
# the time. A missing key raises KeyError, which we turn into a
# MissingField; a non-dict argument raises TypeError, which the
# outer guard translates into the "should be a dict" error.
key = alias or fname
if unpacked_value != "value":
packed_value = "value"
else:
packed_value = f"__{fname}"
unpacked_value = packed_value
if not has_default and len(keys) == 1:
# Required field with a single key: fetch via subscript. On the
# happy path this is noticeably faster than d.get(key, MISSING)
# followed by an identity check, and the field is present the vast
# majority of the time. A missing key raises KeyError, which we
# turn into a MissingField; a non-dict argument raises TypeError,
# which the outer guard translates into the "should be a dict"
# error. With multiple keys we cannot subscript, so we fall back
# to the d.get chain below.
with self.indent("try:"):
self.add_line(f"{packed_value} = d['{key}']")
self.add_line(f"{packed_value} = d['{keys[0]}']")
with self.indent("except KeyError:"):
self.add_line(
f"raise MissingField('{fname}',{field_type},cls) from None"
)
fetched_via_subscript = True
else:
self.add_line(f"value = d.get('{alias or fname}', MISSING)")
packed_value = "value"
self.add_line(f"{packed_value} = d.get('{keys[0]}', MISSING)")
for key in keys[1:]:
with self.indent(f"if {packed_value} is MISSING:"):
self.add_line(f"{packed_value} = d.get('{key}', MISSING)")
if not has_default:
if not fetched_via_subscript:
with self.indent(f"if {packed_value} is MISSING:"):
Expand Down
4 changes: 2 additions & 2 deletions mashumaro/helper.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from collections.abc import Callable
from collections.abc import Callable, Sequence
from typing import Any, TypeVar

from typing_extensions import Literal
Expand Down Expand Up @@ -28,7 +28,7 @@ def field_options(
serialize: AnySerializationEngine | Callable[[Any], Any] | None = None,
deserialize: AnyDeserializationEngine | Callable[[Any], Any] | None = None,
serialization_strategy: SerializationStrategy | None = None,
alias: str | None = None,
alias: str | Sequence[str] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
return {
Expand Down
4 changes: 4 additions & 0 deletions mashumaro/jsonschema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ def alias(self) -> str | None:
if alias is None:
aliases_config = self.get_owner_config().aliases
alias = aliases_config.get(self.name) # type: ignore
# A field may declare several aliases; the schema uses the first
# (primary) one, matching what serialization writes.
if alias is not None and not isinstance(alias, str):
alias = alias[0]
if alias is None:
alias = self.name
return alias
Expand Down
Loading
Loading