Repository: yukinarit/pyserde (python)

Issue:
Bug with `tuple[T, ...]`
I noticed that pyserde 0.9.4 added support for variable length tuples but I think there is a bug with it?

**Examples:**
(Using pyserde 0.9.7)
```python
from serde import serde, from_dict

@serde
class Foo:
    values: tuple[int, ...]

print(from_dict(Foo, {"values": [1, 2, 3, 4]}))  # Prints `Foo(values=(1, 2))` but should be `Foo(values=(1, 2, 3, 4))`
print(from_dict(Foo, {"values": [1, 2, 3]}))  # Prints `Foo(values=(1, 2))` but should be `Foo(values=(1, 2, 3))`
print(from_dict(Foo, {"values": [1, 2]}))  # Prints `Foo(values=(1, 2))`
print(from_dict(Foo, {"values": [1]}))  # Crashes!
print(from_dict(Foo, {"values": []}))  # Crashes!
```


Pull request description:
Fix variable length of tuple
* `Tuple[int]` should accept only a tuple of size 1
* `Tuple[int, bool]` should accept only a tuple of size 2
* `Tuple[int, ...]` should accept empty tuple, tuples of size 1, 2 ... N
* `Tuple[int, bool, ...]` Not allowed
* `Tuple[()]` Not supported


Closes #302

Patch:
diff --git a/examples/runner.py b/examples/runner.py
index 853d1f6..0a444e0 100644
--- a/examples/runner.py
+++ b/examples/runner.py
@@ -8,7 +8,6 @@ import custom_class_serializer
 import custom_field_serializer
 import default
 import default_dict
-import ellipsis
 import env
 import flatten
 import forward_reference
@@ -36,6 +35,7 @@ import type_decimal
 import union
 import union_tagging
 import user_exception
+import variable_length_tuple
 import yamlfile
 
 PY310 = sys.version_info[:3] >= (3, 10, 0)
@@ -72,7 +72,7 @@ def run_all():
     run(type_check_coerce)
     run(user_exception)
     run(pep681)
-    run(ellipsis)
+    run(variable_length_tuple)
     run(init_var)
     run(class_var)
     run(alias)
diff --git a/examples/ellipsis.py b/examples/variable_length_tuple.py
similarity index 85%
rename from examples/ellipsis.py
rename to examples/variable_length_tuple.py
index e597fe5..eac4adc 100644
--- a/examples/ellipsis.py
+++ b/examples/variable_length_tuple.py
@@ -12,10 +12,10 @@ class Foo:
 
 
 def main():
-    f = Foo(v=(10, 'foo'))
+    f = Foo(v=(1, 2, 3))
     print(f"Into Json: {to_json(f)}")
 
-    s = '{"v": [10, "foo"]}'
+    s = '{"v": [1, 2, 3]}'
     print(f"From Json: {from_json(Foo, s)}")
 
 
diff --git a/serde/compat.py b/serde/compat.py
index c67eb78..7e7e5dc 100644
--- a/serde/compat.py
+++ b/serde/compat.py
@@ -241,7 +241,7 @@ def typename(typ: Type[Any], with_typing_module: bool = False) -> str:
         return f'Literal[{", ".join(str(e) for e in args)}]'
     elif typ is Any:
         return f'{mod}Any'
-    elif typ is Ellipsis:
+    elif is_ellipsis(typ):
         return '...'
     else:
         # Get super type for NewType
@@ -569,6 +569,23 @@ def is_bare_tuple(typ: Type[Any]) -> bool:
     return typ in (Tuple, tuple)
 
 
+def is_variable_tuple(typ: Type[Any]) -> bool:
+    """
+    Test if the type is a variable length of tuple `typing.Tuple[T, ...]`.
+
+    >>> from typing import Tuple
+    >>> is_variable_tuple(Tuple[int, ...])
+    True
+    >>> is_variable_tuple(Tuple[int, bool])
+    False
+    >>> is_variable_tuple(Tuple[()])
+    False
+    """
+    istuple = is_tuple(typ) and not is_bare_tuple(typ)
+    args = get_args(typ)
+    return istuple and len(args) == 2 and is_ellipsis(args[1])
+
+
 def is_set(typ: Type[Any]) -> bool:
     """
     Test if the type is `typing.Set` or `typing.FrozenSet`.
@@ -791,6 +808,10 @@ def is_datetime_instance(obj: Any) -> bool:
     return isinstance(obj, DateTimeTypes)
 
 
+def is_ellipsis(typ: Any) -> bool:
+    return typ is Ellipsis
+
+
 def find_generic_arg(cls: Type[Any], field: TypeVar) -> int:
     """
     Find a type in generic parameters.
diff --git a/serde/core.py b/serde/core.py
index ec46d91..4957cec 100644
--- a/serde/core.py
+++ b/serde/core.py
@@ -33,6 +33,7 @@ from .compat import (
     is_set,
     is_tuple,
     is_union,
+    is_variable_tuple,
     type_args,
     typename,
 )
@@ -251,6 +252,11 @@ def is_set_instance(obj: Any, typ: Type) -> bool:
 def is_tuple_instance(obj: Any, typ: Type) -> bool:
     if not isinstance(obj, tuple):
         return False
+    if is_variable_tuple(typ):
+        arg = type_args(typ)[0]
+        for v in obj:
+            if not is_instance(v, arg):
+                return False
     if len(obj) == 0 or is_bare_tuple(typ):
         return True
     for i, arg in enumerate(type_args(typ)):
diff --git a/serde/de.py b/serde/de.py
index 85fee4d..7321f8f 100644
--- a/serde/de.py
+++ b/serde/de.py
@@ -33,6 +33,7 @@ from .compat import (
     is_datetime,
     is_default_dict,
     is_dict,
+    is_ellipsis,
     is_enum,
     is_frozen_set,
     is_generic,
@@ -45,6 +46,7 @@ from .compat import (
     is_str_serializable,
     is_tuple,
     is_union,
+    is_variable_tuple,
     iter_literals,
     iter_types,
     iter_unions,
@@ -425,7 +427,7 @@ def from_obj(c: Type, o: Any, named: bool, reuse_instances: bool):
                 res = set(thisfunc(type_args(c)[0], e) for e in o)
                 return res
         elif is_tuple(c):
-            if is_bare_tuple(c):
+            if is_bare_tuple(c) or is_variable_tuple(c):
                 return tuple(e for e in o)
             else:
                 res = tuple(thisfunc(type_args(c)[i], e) for i, e in enumerate(o))
@@ -448,7 +450,7 @@ def from_obj(c: Type, o: Any, named: bool, reuse_instances: bool):
             return deserialize_numpy_array_direct(c, o)
         elif is_datetime(c):
             return c.fromisoformat(o)
-        elif is_any(c) or c is Ellipsis:
+        elif is_any(c) or is_ellipsis(c):
             return o
 
         return c(o)
@@ -641,7 +643,7 @@ class Renderer:
                     if reuse_instances else {from_iso}"
         elif is_none(arg.type):
             res = "None"
-        elif arg.type is Any or arg.type is Ellipsis:
+        elif is_any(arg.type) or is_ellipsis(arg.type):
             res = arg.data
         elif isinstance(arg.type, TypeVar):
             index = find_generic_arg(self.cls, arg.type)
@@ -786,7 +788,7 @@ Foo.__serde__.funcs[\\'foo\\'](data=data["d"][3], maybe_generic=maybe_generic, r
 [coerce(int, v) for v in data[0][2]], Foo.__serde__.funcs['foo'](data=data[0][3], \
 maybe_generic=maybe_generic, reuse_instances=reuse_instances),)"
         """
-        if is_bare_tuple(arg.type):
+        if is_bare_tuple(arg.type) or is_variable_tuple(arg.type):
             return f'tuple({arg.data})'
         else:
             values = []
diff --git a/serde/se.py b/serde/se.py
index caf5bc8..47897ad 100644
--- a/serde/se.py
+++ b/serde/se.py
@@ -19,6 +19,7 @@ from .compat import (
     SerdeError,
     SerdeSkip,
     get_origin,
+    is_any,
     is_bare_dict,
     is_bare_list,
     is_bare_opt,
@@ -28,6 +29,7 @@ from .compat import (
     is_datetime,
     is_datetime_instance,
     is_dict,
+    is_ellipsis,
     is_enum,
     is_generic,
     is_list,
@@ -40,6 +42,7 @@ from .compat import (
     is_str_serializable_instance,
     is_tuple,
     is_union,
+    is_variable_tuple,
     iter_types,
     iter_unions,
     type_args,
@@ -719,7 +722,7 @@ convert_sets=convert_sets), coerce(int, foo[2]),)"
             res = f"{arg.varname} if reuse_instances else {arg.varname}.isoformat()"
         elif is_none(arg.type):
             res = "None"
-        elif arg.type is Any or arg.type is Ellipsis or isinstance(arg.type, TypeVar):
+        elif is_any(arg.type) or isinstance(arg.type, TypeVar):
             res = f"to_obj({arg.varname}, True, False, False)"
         elif is_generic(arg.type):
             arg.type = get_origin(arg.type)
@@ -801,7 +804,7 @@ convert_sets=convert_sets), coerce(int, foo[2]),)"
         """
         Render rvalue for tuple.
         """
-        if is_bare_tuple(arg.type):
+        if is_bare_tuple(arg.type) or is_variable_tuple(arg.type):
             return arg.varname
         else:
             rvalues = []


Test patch:
diff --git a/tests/common.py b/tests/common.py
index bd452e8..fcf8793 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -80,7 +80,7 @@ types: List = [
     param({1, 2}, FrozenSet[int], toml_not_supported),
     param((1, 1), Tuple[int, int]),
     param((1, 1), Tuple),
-    param((1, 1), Tuple[int, ...]),
+    param((1, 2, 3), Tuple[int, ...]),
     param({'a': 1}, Dict[str, int]),
     param({'a': 1}, Dict),
     param({'a': 1}, dict),
diff --git a/tests/test_basics.py b/tests/test_basics.py
index ad64b72..2902b82 100644
--- a/tests/test_basics.py
+++ b/tests/test_basics.py
@@ -64,6 +64,14 @@ def test_simple(se, de, opt, t, T, f):
         assert t == de(T, se(t))
 
 
+@pytest.mark.parametrize('t,T,f', types, ids=type_ids())
+@pytest.mark.parametrize('named', (True, False))
+@pytest.mark.parametrize('reuse', (True, False))
+def test_from_to_obj(t, T, f, named, reuse):
+    obj = serde.se.to_obj(t, named, reuse, False)
+    assert t == serde.de.from_obj(T, obj, named, reuse)
+
+
 @pytest.mark.parametrize('t,T,filter', types, ids=type_ids())
 @pytest.mark.parametrize('opt', opt_case, ids=opt_case_ids())
 @pytest.mark.parametrize('se,de', (format_dict + format_tuple))
@@ -244,36 +252,43 @@ def test_enum_imported(se, de):
 @pytest.mark.parametrize('se,de', all_formats)
 def test_tuple(se, de, opt):
     @serde.serde(**opt)
+    @dataclasses.dataclass
     class Homogeneous:
         i: Tuple[int, int]
         s: Tuple[str, str]
         f: Tuple[float, float]
         b: Tuple[bool, bool]
 
-    p = Homogeneous((10, 20), ('a', 'b'), (10.0, 20.0), (True, False))
-    assert p == de(Homogeneous, se(p))
+    a = Homogeneous((10, 20), ('a', 'b'), (10.0, 20.0), (True, False))
+    assert a == de(Homogeneous, se(a))
 
     # List will be type mismatch if type_check=True.
-    p = Homogeneous([10, 20], ['a', 'b'], [10.0, 20.0], [True, False])
-    p != de(Homogeneous, se(p))
+    a = Homogeneous([10, 20], ['a', 'b'], [10.0, 20.0], [True, False])
+    a != de(Homogeneous, se(a))
 
     @serde.serde(**opt)
+    @dataclasses.dataclass
     class Variant:
         t: Tuple[int, str, float, bool]
 
     # Toml doesn't support variant type of array.
     if se is not serde.toml.to_toml:
-        p = Variant((10, 'a', 10.0, True))
-        assert p == de(Variant, se(p))
+        b = Variant((10, 'a', 10.0, True))
+        assert b == de(Variant, se(b))
 
     @serde.serde(**opt)
+    @dataclasses.dataclass
     class BareTuple:
         t: Tuple
 
-    p = BareTuple((10, 20))
-    assert p == de(BareTuple, se(p))
+    c = BareTuple((10, 20))
+    assert c == de(BareTuple, se(c))
+
+    c = BareTuple(())
+    assert c == de(BareTuple, se(c))
 
     @serde.serde(**opt)
+    @dataclasses.dataclass
     class Nested:
         i: Tuple[data.Int, data.Int]
         s: Tuple[data.Str, data.Str]
@@ -282,13 +297,31 @@ def test_tuple(se, de, opt):
 
     # hmmm.. Nested tuple doesn't work ..
     if se is not serde.toml.to_toml:
-        p = Nested(
+        d = Nested(
             (data.Int(10), data.Int(20)),
             (data.Str("a"), data.Str("b")),
             (data.Float(10.0), data.Float(20.0)),
             (data.Bool(True), data.Bool(False)),
         )
-        assert p == de(Nested, se(p))
+        assert d == de(Nested, se(d))
+
+    @serde.serde(**opt)
+    @dataclasses.dataclass
+    class VariableTuple:
+        t: Tuple[int, ...]
+
+    e = VariableTuple((1, 2, 3))
+    assert e == de(VariableTuple, se(e))
+
+    e = VariableTuple(())
+    assert e == de(VariableTuple, se(e))
+
+    with pytest.raises(Exception):
+
+        @serde.serde(**opt)
+        @dataclasses.dataclass
+        class EmptyTuple:
+            t: Tuple[()]
 
 
 @pytest.mark.parametrize('se,de', all_formats)
@@ -809,6 +842,11 @@ test_cases = [
     (Tuple[int], (10.0,), True),
     (Tuple[int, str], (10, "foo"), False),
     (Tuple[int, str], (10, 10.0), True),
+    (Tuple[data.Int, data.Str], (data.Int(1), data.Str("2")), False),
+    (Tuple[data.Int, data.Str], (data.Int(1), data.Int(2)), True),
+    (Tuple, (10, 10.0), False),
+    (Tuple[int, ...], (1, 2), False),
+    (Tuple[int, ...], (1, 2.0), True),
     (data.E, data.E.S, False),
     (data.E, data.IE.V0, False),  # TODO enum type check is not yet perfect
     (Union[int, str], 10, False),
diff --git a/tests/test_compat.py b/tests/test_compat.py
index 7a2541a..210c355 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -204,7 +204,10 @@ def test_is_instance():
     assert is_instance((10, "a"), tuple)
     assert is_instance((10, 'foo', 100.0, True), Tuple[int, str, float, bool])
     assert not is_instance((10, 'foo', 100.0, "last-type-is-wrong"), Tuple[int, str, float, bool])
-    assert is_instance((10, "a"), Tuple[int, ...])
+    assert is_instance((10, 20), Tuple[int, ...])
+    assert is_instance((10, 20, 30), Tuple[int, ...])
+    assert is_instance((), Tuple[int, ...])
+    assert not is_instance((10, "a"), Tuple[int, ...])
 
     # Tuple of dataclasses
     assert is_instance((Int(10), Str('foo'), Float(100.0), Bool(True)), Tuple[Int, Str, Float, Bool])