Repository: yukinarit/pyserde (python)

Issue:
Support for aliasing on dataclass fields
Similarly to [field attributes](https://serde.rs/field-attrs.html) in the serde.rs crate it would be very useful to be able to describe multiple aliases for the same field, that way you're not stuck if multiple names exist while de-serializing.

Something like this would be useful
```py
@dataclass
@serde
class Foo:
    bar_field: int
    name_field: str = field(alias = ["first_alias", "second_alias", "third_alias" ]
```

The syntax is definitely up for debate, since in the serde library it's `#[serde(alias = "one", alias = "two", alias = "three"] which I don't think is allowed in python.

This change would allow the deserialization of something like

```json
[
	{
		"bar_field": 1,
		"first_alias": "Would work"
	},
	{
		"bar_field": 2,
		"second_alias": "Also works"
	},
	{
		"bar_field": 3,
		"third_alias": "And these all deserialize correctly"
	}
]
```
 to deserialize all into the same Foo objects.

It also makes the library really flexible when you want to make different names more than once, such as using a more descriptive name on swagger docs.

Pull request description:
feat: Implement alias
* `alias` works only during deserialization
* `rename` + `alias` works as expected
* If alias conflicts, the first one wins

```python
@serde
@dataclass
class Foo:
    a: int = field(alias=["b", "c", "d"])
```

Closes #253

Patch:
diff --git a/README.md b/README.md
index b74096b..e703e7f 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 0d990ae..da6336b 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -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)
diff --git a/docs/features/alias.md b/docs/features/alias.md
new file mode 100644
index 0000000..4edf5fb
--- /dev/null
+++ b/docs/features/alias.md
@@ -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)
diff --git a/docs/features/rename.md b/docs/features/rename.md
index 277c9ea..5f29cfa 100644
--- a/docs/features/rename.md
+++ b/docs/features/rename.md
@@ -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')))
 ```
diff --git a/docs/introduction.md b/docs/introduction.md
index 62d663e..f1f3308 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -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)
diff --git a/examples/alias.py b/examples/alias.py
new file mode 100644
index 0000000..afabbb3
--- /dev/null
+++ b/examples/alias.py
@@ -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()
diff --git a/examples/runner.py b/examples/runner.py
index 39b39fc..6116c51 100644
--- a/examples/runner.py
+++ b/examples/runner.py
@@ -1,5 +1,6 @@
 import sys
 
+import alias
 import any
 import class_var
 import collection
@@ -67,6 +68,7 @@ def run_all():
     run(ellipsis)
     run(init_var)
     run(class_var)
+    run(alias)
     if PY310:
         import union_operator
 
diff --git a/serde/core.py b/serde/core.py
index d9eb3a3..4f9f650 100644
--- a/serde/core.py
+++ b/serde/core.py
@@ -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,
@@ -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:
@@ -432,6 +435,7 @@ class Field:
     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
@@ -486,6 +490,7 @@ class 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,
diff --git a/serde/de.py b/serde/de.py
index 0c913b9..6e6d583 100644
--- a/serde/de.py
+++ b/serde/de.py
@@ -79,7 +79,7 @@ from .numpy import (
     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]
@@ -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,
@@ -233,6 +242,7 @@ def deserialize(
         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
@@ -755,6 +765,9 @@ for k, v in data["f"].items()}'
         """
         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:


Test patch:
diff --git a/tests/test_basics.py b/tests/test_basics.py
index 6e3cd96..d4237d8 100644
--- a/tests/test_basics.py
+++ b/tests/test_basics.py
@@ -454,6 +454,50 @@ def test_rename_formats(se, de):
     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