Repository: yukinarit/pyserde
Pull request #509 (2024-04-01 11:05:08)

Handle empty tuple more properly


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 2cc4da8..a4da303 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -156,5 +156,5 @@ select = [
 ]
 line-length = 100
 
-[tool.ruff.mccabe]
+[tool.ruff.lint.mccabe]
 max-complexity = 30
diff --git a/serde/core.py b/serde/core.py
index 883e688..71f47e0 100644
--- a/serde/core.py
+++ b/serde/core.py
@@ -11,6 +11,7 @@ import sys
 import re
 import casefy
 from dataclasses import dataclass
+from beartype.door import is_bearable
 from collections.abc import Mapping, Sequence, Callable
 from typing import (
     overload,
@@ -343,8 +344,8 @@ def add_func(serde_scope: Scope, func_name: str, func_code: str, globals: dict[s
 
 def is_instance(obj: Any, typ: Any) -> bool:
     """
-    Type check function that works like `isinstance` but it accepts
-    Subscripted Generics e.g. `list[int]`.
+    pyserde's own `isinstance` helper. It accepts subscripted generics e.g. `list[int]` and
+    deeply check object against declared type.
     """
     if dataclasses.is_dataclass(typ):
         return isinstance(obj, typ)
@@ -375,7 +376,7 @@ def is_instance(obj: Any, typ: Any) -> bool:
     elif typ is Ellipsis:
         return True
     else:
-        return isinstance(obj, typ)
+        return is_bearable(obj, typ)
 
 
 def is_opt_instance(obj: Any, typ: type[Any]) -> bool:
@@ -413,18 +414,37 @@ def is_set_instance(obj: Any, typ: type[Any]) -> bool:
 
 
 def is_tuple_instance(obj: Any, typ: type[Any]) -> bool:
+    args = type_args(typ)
+
     if not isinstance(obj, tuple):
         return False
-    if is_variable_tuple(typ):
+
+    # empty tuple
+    if len(args) == 0 and len(obj) == 0:
+        return True
+
+    # In the form of tuple[T, ...]
+    elif is_variable_tuple(typ):
+        # Get the first type arg. Since tuple[T, ...] is homogeneous tuple,
+        # all the elements should be of this type.
         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)):
-        if not is_instance(obj[i], arg):
-            return False
+
+    # bare tuple "tuple" is equivalent to tuple[Any, ...]
+    if is_bare_tuple(typ) and isinstance(obj, tuple):
+        return True
+
+    # All the other tuples e.g. tuple[int, str]
+    if len(obj) == len(args):
+        for element, arg in zip(obj, args):
+            if not is_instance(element, arg):
+                return False
+    else:
+        return False
+
     return True
 
 


Test patch:
diff --git a/tests/test_compat.py b/tests/test_compat.py
index b48565c..b99e95d 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -158,7 +158,7 @@ def test_union_args() -> None:
 
 
 def test_is_instance() -> None:
-    # Primitive
+    # primitive
     assert is_instance(10, int)
     assert is_instance("str", str)
     assert is_instance(1.0, float)
@@ -167,13 +167,13 @@ def test_is_instance() -> None:
     assert not is_instance("10", int)
     assert is_instance(True, int)  # see why this is true https://stackoverflow.com/a/37888668
 
-    # Dataclass
+    # dataclass
     p = Pri(i=10, s="foo", f=100.0, b=True)
     assert is_instance(p, Pri)
     p.i = 10.0  # type: ignore
     assert is_instance(p, Pri)  # there is no way to check mulated properties.
 
-    # Dataclass (Nested)
+    # dataclass (Nested)
     @dataclass
     class Foo:
         p: Pri
@@ -190,7 +190,7 @@ def test_is_instance() -> None:
     assert is_instance([10], list[int])
     assert not is_instance([10.0], list[int])
 
-    # list of dataclasses
+    # list of dataclass
     assert is_instance([Int(n) for n in range(1, 10)], list[Int])
     assert not is_instance([Str("foo")], list[Int])
 
@@ -200,18 +200,18 @@ def test_is_instance() -> None:
     assert is_instance({10}, set[int])
     assert not is_instance({10.0}, set[int])
 
-    # set of dataclasses
+    # set of dataclass
     assert is_instance({Int(n) for n in range(1, 10)}, set[Int])
     assert not is_instance({Str("foo")}, set[Int])
 
     # tuple
-    assert is_instance((), tuple[int, str, float, bool])
-    assert is_instance((10, "a"), tuple)
+    assert not is_instance((), tuple[int, str, float, bool])
     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, 20), tuple[int, ...])
     assert is_instance((10, 20, 30), tuple[int, ...])
+    assert is_instance((), tuple[()])
     assert is_instance((), tuple[int, ...])
     assert not is_instance((10, "a"), tuple[int, ...])
 
@@ -230,21 +230,21 @@ def test_is_instance() -> None:
     assert is_instance({"foo": 10, "bar": 20}, dict[str, int])
     assert not is_instance({"foo": 10.0, "bar": 20}, dict[str, int])
 
-    # dict of dataclasses
+    # dict of dataclass
     assert is_instance({Str("foo"): Int(10), Str("bar"): Int(20)}, dict[Str, Int])
     assert not is_instance({Str("foo"): Str("wrong-type"), Str("bar"): Int(10)}, dict[Str, Int])
 
-    # Optional
+    # optional
     assert is_instance(None, type(None))
     assert is_instance(10, Optional[int])
     assert is_instance(None, Optional[int])
     assert not is_instance("wrong-type", Optional[int])
 
-    # Optional of dataclass
+    # optional of dataclass
     assert is_instance(Int(10), Optional[Int])
     assert not is_instance("wrong-type", Optional[Int])
 
-    # Nested containers
+    # nested containers
     assert is_instance([({"a": "b"}, 10, [True])], list[tuple[dict[str, str], int, list[bool]]])
     assert not is_instance(
         [({"a": "b"}, 10, ["wrong-type"])], list[tuple[dict[str, str], int, list[bool]]]