Skip to content

Commit

Permalink
feat: Implement alias
Browse files Browse the repository at this point in the history
* `alias` works only during deserialization
* `rename` + `alias` works as expected
* If alias conflicts, the first one wins

```
@serde
@DataClass
class Foo:
    a: int = field(alias=["b", "c", "d"])
```
  • Loading branch information
yukinarit committed Nov 26, 2022
1 parent db792cf commit 36cbc6e
Show file tree
Hide file tree
Showing 10 changed files with 118 additions and 3 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Foo(i=10, s='foo', f=100.0, b=True)
- [Forward reference](docs/features/forward-reference.md)
- [Case Conversion](docs/features/case-conversion.md)
- [Rename](docs/features/rename.md)
- [Alias](docs/features/alias.md)
- [Skip](docs/features/skip.md)
- [Conditional Skip](docs/features/conditional-skip.md)
- [Custom field (de)serializer](docs/features/custom-field-serializer.md)
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- [Forward reference](features/forward-reference.md)
- [Case Conversion](features/case-conversion.md)
- [Rename](features/rename.md)
- [Alias](features/alias.md)
- [Skip](features/skip.md)
- [Conditional Skip](features/conditional-skip.md)
- [Custom field (de)serializer](features/custom-field-serializer.md)
Expand Down
14 changes: 14 additions & 0 deletions docs/features/alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Alias

You can set aliases for field names. Alias only works for deserialization.

```python
@serde
@dataclass
class Foo:
a: str = field(alias=["b", "c"])
```

`Foo` can be deserialized from either `{"a": "..."}`, `{"b": "..."}` or `{"c": "..."}`.

For complete example, please see [examples/alias.py](https://github.com/yukinarit/pyserde/blob/master/examples/alias.py)
4 changes: 2 additions & 2 deletions docs/features/rename.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Rename

In case you want to use a keyword as field such as `class`, you can use `serde_rename` field attribute.
In case you want to use a keyword as field such as `class`, you can use `rename` field attribute. If you want to have multiple aliases, you can use [alias](alias.md).

```python
@serde
@dataclass
class Foo:
class_name: str = field(metadata={'serde_rename': 'class'})
class_name: str = field(rename='class')

print(to_json(Foo(class_name='Foo')))
```
Expand Down
1 change: 1 addition & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Foo(i=10, s='foo', f=100.0, b=True)
- [Forward reference](features/forward-reference.md)
- [Case Conversion](features/case-conversion.md)
- [Rename](features/rename.md)
- [Alias](features/alias.md)
- [Skip](features/skip.md)
- [Conditional Skip](features/conditional-skip.md)
- [Custom field (de)serializer](features/custom-field-serializer.md)
Expand Down
34 changes: 34 additions & 0 deletions examples/alias.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from dataclasses import dataclass

from serde import field, serde
from serde.json import from_json


@serde
@dataclass
class Foo:
a: int = field(alias=["b", "c", "d"])


def main():
s = '{"a": 10}'
print(f"From Json: {from_json(Foo, s)}")

s = '{"b": 20}'
print(f"From Json: {from_json(Foo, s)}")

s = '{"c": 30}'
print(f"From Json: {from_json(Foo, s)}")

s = '{"d": 40}'
print(f"From Json: {from_json(Foo, s)}")

try:
s = '{"e": 50}'
print(f"From Json: {from_json(Foo, s)}")
except Exception:
pass


if __name__ == '__main__':
main()
2 changes: 2 additions & 0 deletions examples/runner.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys

import alias
import any
import class_var
import collection
Expand Down Expand Up @@ -67,6 +68,7 @@ def run_all():
run(ellipsis)
run(init_var)
run(class_var)
run(alias)
if PY310:
import union_operator

Expand Down
5 changes: 5 additions & 0 deletions serde/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ class FlattenOpts:
def field(
*args,
rename: Optional[str] = None,
alias: Optional[List[str]] = None,
skip: Optional[bool] = None,
skip_if: Optional[Callable] = None,
skip_if_false: Optional[bool] = None,
Expand All @@ -339,6 +340,8 @@ def field(

if rename is not None:
metadata["serde_rename"] = rename
if alias is not None:
metadata["serde_alias"] = alias
if skip is not None:
metadata["serde_skip"] = skip
if skip_if is not None:
Expand Down Expand Up @@ -432,6 +435,7 @@ class Foo:
compare: Any = field(default_factory=dataclasses._MISSING_TYPE)
metadata: Mapping[str, Any] = field(default_factory=dict)
case: Optional[str] = None
alias: List[str] = field(default_factory=list)
rename: Optional[str] = None
skip: Optional[bool] = None
skip_if: Optional[Func] = None
Expand Down Expand Up @@ -486,6 +490,7 @@ def from_dataclass(cls, f: dataclasses.Field) -> 'Field':
compare=f.compare,
metadata=f.metadata,
rename=f.metadata.get('serde_rename'),
alias=f.metadata.get('serde_alias', []),
skip=f.metadata.get('serde_skip'),
skip_if=skip_if or skip_if_false_func or skip_if_default_func,
serializer=serializer,
Expand Down
15 changes: 14 additions & 1 deletion serde/de.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
is_numpy_scalar,
)

__all__: List = ['deserialize', 'is_deserializable', 'from_dict', 'from_tuple']
__all__ = ['deserialize', 'is_deserializable', 'from_dict', 'from_tuple']

# Interface of Custom deserialize function.
DeserializeFunc = Callable[[Type, Any], Any]
Expand Down Expand Up @@ -108,6 +108,15 @@ def default_deserializer(_cls: Type, obj):
"""


def _get_by_aliases(d: Dict[str, str], aliases: List[str]):
if not aliases:
raise KeyError("Tried all aliases, but key not found")
if aliases[0] in d:
return d[aliases[0]]
else:
return _get_by_aliases(d, aliases[1:])


def _make_deserialize(
cls_name: str,
fields,
Expand Down Expand Up @@ -233,6 +242,7 @@ def wrap(cls: Type):
g['TypeCheck'] = TypeCheck
g['NoCheck'] = NoCheck
g['coerce'] = coerce
g['_get_by_aliases'] = _get_by_aliases
if deserialize:
g['serde_custom_class_deserializer'] = functools.partial(
serde_custom_class_deserializer, custom=deserializer
Expand Down Expand Up @@ -755,6 +765,9 @@ def primitive(self, arg: DeField, suppress_coerce: bool = False) -> str:
"""
typ = typename(arg.type)
dat = arg.data
if arg.alias:
aliases = map(lambda s: f'"{s}"', [arg.name, *arg.alias])
dat = f"_get_by_aliases(data, [{','.join(aliases)}])"
if self.suppress_coerce:
return dat
else:
Expand Down
44 changes: 44 additions & 0 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,50 @@ class Foo:
assert f == de(Foo, se(f))


@pytest.mark.parametrize('se,de', (format_dict + format_json + format_yaml + format_toml))
def test_alias(se, de):
@serde.serde
class Foo:
a: str = serde.field(alias=["b", "c", "d"])

f = Foo(a='foo')
assert f == de(Foo, se(f))


def test_conflicting_alias():
@serde.serde
class Foo:
a: int = serde.field(alias=["b", "c", "d"])
b: int
c: int
d: int

f = Foo(a=1, b=2, c=3, d=4)
assert '{"a":1,"b":2,"c":3,"d":4}' == serde.json.to_json(f)
ff = serde.json.from_json(Foo, '{"a":1,"b":2,"c":3,"d":4}')
assert ff.a == 1
assert ff.b == 2
assert ff.c == 3
assert ff.d == 4

ff = serde.json.from_json(Foo, '{"b":2,"c":3,"d":4}')
assert ff.a == 2
assert ff.b == 2
assert ff.c == 3
assert ff.d == 4


def test_rename_and_alias():
@serde.serde
class Foo:
a: int = serde.field(rename="z", alias=["b", "c", "d"])

f = Foo(a=1)
assert '{"z":1}' == serde.json.to_json(f)
ff = serde.json.from_json(Foo, '{"b":10}')
assert ff.a == 10


@pytest.mark.parametrize('se,de', (format_dict + format_json + format_msgpack + format_yaml + format_toml))
def test_skip_if(se, de):
@serde.serde
Expand Down

0 comments on commit 36cbc6e

Please sign in to comment.