Repository: yukinarit/pyserde (python)

Issue:
`ClassVar`s don't get serialized?
Example:
```from typing import ClassVar
from serde.se import serialize, to_dict

@serialize
class Foo:
    value: ClassVar[str] = "abc"

print(to_dict(Foo()))  # Prints {}
```

Pull request description:
feat: Support optional ClassVar serialization
```
@serialize(serialize_class_var=True)
class Foo:
    v: typing.ClassVar[int] = 10
```

Closes #296 

Patch:
diff --git a/README.md b/README.md
index ac5b62a..f6bd4e9 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,7 @@ Foo(i=10, s='foo', f=100.0, b=True)
     - [`typing.Any`](https://docs.python.org/3/library/typing.html#the-any-type)
     - [`typing.Literal`](https://docs.python.org/3/library/typing.html#typing.Literal)
     - [`typing.Generic`](https://docs.python.org/3/library/typing.html#user-defined-generic-types)
+    - [`typing.ClassVar`](https://docs.python.org/3/library/typing.html#user-defined-generic-type://docs.python.org/3/library/typing.html#typing.ClassVar)
     - [`Enum`](https://docs.python.org/3/library/enum.html#enum.Enum) and [`IntEnum`](https://docs.python.org/3/library/enum.html#enum.IntEnum)
     - Standard library
         - [`pathlib.Path`](https://docs.python.org/3/library/pathlib.html)
diff --git a/docs/introduction.md b/docs/introduction.md
index f1f3308..a136337 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -40,6 +40,7 @@ Foo(i=10, s='foo', f=100.0, b=True)
     - [`typing.NewType`](https://docs.python.org/3/library/typing.html#newtype) for primitive types
     - [`typing.Any`](https://docs.python.org/3/library/typing.html#the-any-type)
     - [`typing.Generic`](https://docs.python.org/3/library/typing.html#user-defined-generic-types)
+    - [`typing.ClassVar`](https://docs.python.org/3/library/typing.html#user-defined-generic-type://docs.python.org/3/library/typing.html#typing.ClassVar)
     - [`Enum`](https://docs.python.org/3/library/enum.html#enum.Enum) and [`IntEnum`](https://docs.python.org/3/library/enum.html#enum.IntEnum)
     - Standard library
         - [`pathlib.Path`](https://docs.python.org/3/library/pathlib.html)
diff --git a/docs/supported-types.md b/docs/supported-types.md
index a60d5e5..916cc84 100644
--- a/docs/supported-types.md
+++ b/docs/supported-types.md
@@ -11,6 +11,7 @@
 * [`typing.Any`](https://docs.python.org/3/library/typing.html#the-any-type)
 * [`typing.Literal`](https://docs.python.org/3/library/typing.html#typing.Literal)
 * [`typing.Generic`](https://docs.python.org/3/library/typing.html#user-defined-generic-types)
+* [`typing.ClassVar`](https://docs.python.org/3/library/typing.html#user-defined-generic-type://docs.python.org/3/library/typing.html#typing.ClassVar)
 * [`Enum`](https://docs.python.org/3/library/enum.html#enum.Enum) and [`IntEnum`](https://docs.python.org/3/library/enum.html#enum.IntEnum)
 * Standard library
     * [`pathlib.Path`](https://docs.python.org/3/library/pathlib.html)
diff --git a/examples/class_var.py b/examples/class_var.py
index 1cc921d..72f7934 100644
--- a/examples/class_var.py
+++ b/examples/class_var.py
@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from typing import ClassVar, Optional
+from typing import ClassVar, List
 
 from serde import serde
 from serde.json import from_json, to_json
@@ -7,20 +7,22 @@ from serde.json import from_json, to_json
 
 @serde
 @dataclass
-class Foo:
-    i: int
-    j: Optional[int] = None
-    k: ClassVar[int] = 100
+class Bar:
+    v: int
 
 
-def main():
-    f = Foo(i=10, j=20)
-    print(f"Into Json: {to_json(f)}")
+@serde(serialize_class_var=True)
+@dataclass
+class Foo:
+    a: ClassVar[int] = 10
+    b: ClassVar[Bar] = Bar(100)
+    c: ClassVar[List[Bar]] = [Bar(1), Bar(2)]
 
-    s = '{"i": 10, "j": 20}'
-    print(f"From Json: {from_json(Foo, s)}")
 
-    print(f"Class variable: Foo.k={f.k}")
+def main() -> None:
+    f = Foo()
+    print(f"Into Json: {to_json(f)}")
+    print(f"From Json: {from_json(Foo, '{}')}")
 
 
 if __name__ == '__main__':
diff --git a/examples/runner.py b/examples/runner.py
index aa9ecdd..cee7f75 100644
--- a/examples/runner.py
+++ b/examples/runner.py
@@ -75,6 +75,7 @@ def run_all():
     run(class_var)
     run(alias)
     run(recursive)
+    run(class_var)
     if PY310:
         import union_operator
 
diff --git a/serde/__init__.py b/serde/__init__.py
index 024f45e..b77f602 100644
--- a/serde/__init__.py
+++ b/serde/__init__.py
@@ -126,6 +126,7 @@ def serde(
     deserializer: Optional[DeserializeFunc] = None,
     tagging: Tagging = DefaultTagging,
     type_check: TypeCheck = NoCheck,
+    serialize_class_var: bool = False,
 ) -> Callable[[Type[T]], Type[T]]:
     ...
 
diff --git a/serde/compat.py b/serde/compat.py
index fbceb9a..c67eb78 100644
--- a/serde/compat.py
+++ b/serde/compat.py
@@ -15,14 +15,27 @@ import typing
 import uuid
 from collections import defaultdict
 from dataclasses import is_dataclass
-from typing import Any, DefaultDict, Dict, FrozenSet, Generic, Iterator, List, Optional, Set, Tuple, TypeVar, Union
+from typing import (
+    Any,
+    ClassVar,
+    DefaultDict,
+    Dict,
+    FrozenSet,
+    Generic,
+    Iterator,
+    List,
+    Optional,
+    Set,
+    Tuple,
+    TypeVar,
+    Union,
+)
 
+import typing_extensions
 import typing_inspect
 from typing_extensions import Type
 
 if sys.version_info[:2] == (3, 7):
-    import typing_extensions
-
     Literal = typing_extensions.Literal
 else:
     Literal = typing.Literal
@@ -124,7 +137,7 @@ def get_origin(typ: Type[Any]) -> Optional[Any]:
     try:
         return typing.get_origin(typ) or get_np_origin(typ)  # python>=3.8 typing module has get_origin.
     except AttributeError:
-        return typing_inspect.get_origin(typ) or get_np_origin(typ)
+        return typing_extensions.get_origin(typ) or get_np_origin(typ)
 
 
 def get_args(typ: Any) -> Tuple[Any, ...]:
@@ -134,7 +147,7 @@ def get_args(typ: Any) -> Tuple[Any, ...]:
     try:
         return typing.get_args(typ) or get_np_args(typ)  # python>=3.8 typing module has get_args.
     except AttributeError:
-        return typing_inspect.get_args(typ) or get_np_args(typ)
+        return typing_extensions.get_args(typ) or get_np_args(typ)
 
 
 def typename(typ: Type[Any], with_typing_module: bool = False) -> str:
@@ -719,6 +732,18 @@ def is_generic(typ: Type[Any]) -> bool:
     return origin is not None and Generic in getattr(origin, "__bases__", ())
 
 
+def is_class_var(typ: Type[Any]) -> bool:
+    """
+    Test if the type is `typing.ClassVar`.
+
+    >>> is_class_var(ClassVar[int])
+    True
+    >>> is_class_var(ClassVar)
+    True
+    """
+    return get_origin(typ) is ClassVar or typ is ClassVar  # type: ignore
+
+
 def is_literal(typ: Type[Any]) -> bool:
     """
     Test if the type is derived from `typing.Literal`.
diff --git a/serde/core.py b/serde/core.py
index 9829029..ec46d91 100644
--- a/serde/core.py
+++ b/serde/core.py
@@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, List, Mapping, Optional, TypeVar, Union
 
 import casefy
 import jinja2
-from typing_extensions import Type
+from typing_extensions import Type, get_type_hints
 
 from .compat import (
     SerdeError,
@@ -23,6 +23,7 @@ from .compat import (
     is_bare_list,
     is_bare_set,
     is_bare_tuple,
+    is_class_var,
     is_dict,
     is_generic,
     is_list,
@@ -543,11 +544,18 @@ class Field:
 F = TypeVar('F', bound=Field)
 
 
-def fields(field_cls: Type[F], cls: Type) -> List[F]:
+def fields(field_cls: Type[F], cls: Type[Any], serialize_class_var: bool = False) -> List[F]:
     """
     Iterate fields of the dataclass and returns `serde.core.Field`.
     """
-    return [field_cls.from_dataclass(f, parent=cls) for f in dataclass_fields(cls)]
+    fields = [field_cls.from_dataclass(f, parent=cls) for f in dataclass_fields(cls)]
+
+    if serialize_class_var:
+        for name, typ in get_type_hints(cls).items():
+            if is_class_var(typ):
+                fields.append(field_cls(typ, name, default=getattr(cls, name)))
+
+    return fields
 
 
 def conv(f: Field, case: Optional[str] = None) -> str:
diff --git a/serde/se.py b/serde/se.py
index ec63c46..a839cd7 100644
--- a/serde/se.py
+++ b/serde/se.py
@@ -24,6 +24,7 @@ from .compat import (
     is_bare_opt,
     is_bare_set,
     is_bare_tuple,
+    is_class_var,
     is_datetime,
     is_datetime_instance,
     is_dict,
@@ -116,6 +117,9 @@ def _make_serialize(
     reuse_instances_default: bool = True,
     convert_sets_default: bool = False,
     serializer: Optional[SerializeFunc] = None,
+    tagging: Tagging = DefaultTagging,
+    type_check: TypeCheck = NoCheck,
+    serialize_class_var: bool = False,
     **kwargs,
 ):
     """
@@ -127,6 +131,10 @@ def _make_serialize(
         rename_all=rename_all,
         reuse_instances_default=reuse_instances_default,
         convert_sets_default=convert_sets_default,
+        serializer=serializer,
+        tagging=tagging,
+        type_check=type_check,
+        serialize_class_var=serialize_class_var,
         **kwargs,
     )
     return C
@@ -141,6 +149,7 @@ def serialize(
     serializer: Optional[SerializeFunc] = None,
     tagging: Tagging = DefaultTagging,
     type_check: TypeCheck = NoCheck,
+    serialize_class_var: bool = False,
     **kwargs,
 ):
     """
@@ -198,6 +207,15 @@ def serialize(
 
     >>> to_json(Foo(10, datetime(2021, 1, 1, 0, 0, 0)))
     '{"i":10,"dt":"01/01/21"}'
+
+    * `serialize_class_var` enables `typing.ClassVar` serialization.
+    >>> @serialize(serialize_class_var=True)
+    ... class Foo:
+    ...     v: typing.ClassVar[int] = 10
+    >>>
+    >>> to_json(Foo())
+    '{"v":10}'
+
     """
 
     def wrap(cls: Type[Any]):
@@ -253,14 +271,14 @@ def serialize(
             add_func(scope, union_key, render_union_func(cls, union_args, tagging), g)
             scope.union_se_args[union_key] = union_args
 
-        for f in sefields(cls):
+        for f in sefields(cls, serialize_class_var):
             if f.skip_if:
                 g[f.skip_if.name] = f.skip_if
             if f.serializer:
                 g[f.serializer.name] = f.serializer
 
-        add_func(scope, TO_ITER, render_to_tuple(cls, serializer, type_check), g)
-        add_func(scope, TO_DICT, render_to_dict(cls, rename_all, serializer, type_check), g)
+        add_func(scope, TO_ITER, render_to_tuple(cls, serializer, type_check, serialize_class_var), g)
+        add_func(scope, TO_DICT, render_to_dict(cls, rename_all, serializer, type_check, serialize_class_var), g)
         add_func(scope, TYPE_CHECK, render_type_check(cls), g)
 
         logger.debug(f"{typename(cls)}: {SERDE_SCOPE} {scope}")
@@ -416,17 +434,22 @@ class SeField(Field):
         return SeField(typ, name=None)
 
 
-def sefields(cls: Type[Any]) -> Iterator[SeField]:
+def sefields(cls: Type[Any], serialize_class_var: bool = False) -> Iterator[SeField]:
     """
     Iterate fields for serialization.
     """
-    for f in fields(SeField, cls):
+    for f in fields(SeField, cls, serialize_class_var=serialize_class_var):
         f.parent = SeField(None, "obj")  # type: ignore
         assert isinstance(f, SeField)
         yield f
 
 
-def render_to_tuple(cls: Type[Any], custom: Optional[SerializeFunc] = None, type_check: TypeCheck = NoCheck) -> str:
+def render_to_tuple(
+    cls: Type[Any],
+    custom: Optional[SerializeFunc] = None,
+    type_check: TypeCheck = NoCheck,
+    serialize_class_var: bool = False,
+) -> str:
     template = """
 def {{func}}(obj, reuse_instances = {{serde_scope.reuse_instances_default}},
              convert_sets = {{serde_scope.convert_sets_default}}):
@@ -451,16 +474,25 @@ def {{func}}(obj, reuse_instances = {{serde_scope.reuse_instances_default}},
   )
     """
 
-    renderer = Renderer(TO_ITER, custom, suppress_coerce=(not type_check.is_coerce()))
+    renderer = Renderer(
+        TO_ITER, custom, suppress_coerce=(not type_check.is_coerce()), serialize_class_var=serialize_class_var
+    )
     env = jinja2.Environment(loader=jinja2.DictLoader({"iter": template}))
     env.filters.update({"rvalue": renderer.render})
     return env.get_template("iter").render(
-        func=TO_ITER, serde_scope=getattr(cls, SERDE_SCOPE), fields=sefields(cls), type_check=type_check
+        func=TO_ITER,
+        serde_scope=getattr(cls, SERDE_SCOPE),
+        fields=sefields(cls, serialize_class_var),
+        type_check=type_check,
     )
 
 
 def render_to_dict(
-    cls: Type[Any], case: Optional[str] = None, custom: Optional[SerializeFunc] = None, type_check: TypeCheck = NoCheck
+    cls: Type[Any],
+    case: Optional[str] = None,
+    custom: Optional[SerializeFunc] = None,
+    type_check: TypeCheck = NoCheck,
+    serialize_class_var: bool = False,
 ) -> str:
     template = """
 def {{func}}(obj, reuse_instances = {{serde_scope.reuse_instances_default}},
@@ -492,13 +524,16 @@ def {{func}}(obj, reuse_instances = {{serde_scope.reuse_instances_default}},
   return res
     """
     renderer = Renderer(TO_DICT, custom, suppress_coerce=(not type_check.is_coerce()))
-    lrenderer = LRenderer(case)
+    lrenderer = LRenderer(case, serialize_class_var)
     env = jinja2.Environment(loader=jinja2.DictLoader({"dict": template}))
     env.filters.update({"rvalue": renderer.render})
     env.filters.update({"lvalue": lrenderer.render})
     env.filters.update({"case": functools.partial(conv, case=case)})
     return env.get_template("dict").render(
-        func=TO_DICT, serde_scope=getattr(cls, SERDE_SCOPE), fields=sefields(cls), type_check=type_check
+        func=TO_DICT,
+        serde_scope=getattr(cls, SERDE_SCOPE),
+        fields=sefields(cls, serialize_class_var),
+        type_check=type_check,
     )
 
 
@@ -555,6 +590,7 @@ class LRenderer:
     """
 
     case: Optional[str]
+    serialize_class_var: bool = False
 
     def render(self, arg: SeField) -> str:
         """
@@ -570,7 +606,7 @@ class LRenderer:
         Render field with flatten attribute.
         """
         flattened = []
-        for f in sefields(arg.type):
+        for f in sefields(arg.type, self.serialize_class_var):
             flattened.append(self.render(f))
         return ", ".join(flattened)
 
@@ -585,6 +621,7 @@ class Renderer:
     custom: Optional[SerializeFunc] = None  # Custom class level serializer.
     suppress_coerce: bool = False
     """ Suppress type coercing because generated union serializer has its own type checking """
+    serialize_class_var: bool = False
 
     def render(self, arg: SeField) -> str:
         """
@@ -659,6 +696,9 @@ convert_sets=convert_sets), coerce(int, foo[2]),)"
             res = self.render(arg)
         elif is_literal(arg.type):
             res = self.literal(arg)
+        elif is_class_var(arg.type):
+            arg.type = type_args(arg.type)[0]
+            res = self.render(arg)
         else:
             res = f"raise_unsupported_type({arg.varname})"
 
@@ -681,7 +721,7 @@ convert_sets=convert_sets), coerce(int, foo[2]),)"
         """
         if arg.flatten:
             flattened = []
-            for f in sefields(arg.type):
+            for f in sefields(arg.type, self.serialize_class_var):
                 f.parent = arg  # type: ignore
                 flattened.append(self.render(f))  # type: ignore
             return ", ".join(flattened)


Test patch:
diff --git a/tests/test_basics.py b/tests/test_basics.py
index 7346048..cd75e35 100644
--- a/tests/test_basics.py
+++ b/tests/test_basics.py
@@ -4,7 +4,7 @@ import enum
 import logging
 import pathlib
 import uuid
-from typing import DefaultDict, Dict, FrozenSet, List, Optional, Set, Tuple, Union
+from typing import ClassVar, DefaultDict, Dict, FrozenSet, List, Optional, Set, Tuple, Union
 
 import pytest
 
@@ -961,3 +961,66 @@ def test_defaultdict_invalid_value_type() -> None:
 
     with pytest.raises(Exception):
         serde.json.from_json(DefaultDict, '{"k":[1,2]}')
+
+
+def test_class_var() -> None:
+    @serde.serde
+    @dataclasses.dataclass
+    class Disabled:
+        v: ClassVar[int] = 10
+
+    a = Disabled()
+    assert {} == serde.to_dict(a)
+    assert a == serde.from_dict(Disabled, {})
+
+    @serde.serde(serialize_class_var=True)
+    @dataclasses.dataclass
+    class Enabled:
+        v: ClassVar[int] = 10
+
+    b = Enabled()
+    assert {"v": 10} == serde.to_dict(b)
+    assert b == serde.from_dict(Enabled, {})
+
+    @serde.serde
+    @dataclasses.dataclass
+    class Foo:
+        v: int
+
+    @serde.serde(serialize_class_var=True)
+    @dataclasses.dataclass
+    class Dataclass:
+        v: ClassVar[Foo] = Foo(10)
+
+    c = Dataclass()
+    assert {"v": {"v": 10}} == serde.to_dict(c)
+    assert c == serde.from_dict(Dataclass, {})
+
+    @serde.serde(serialize_class_var=True)
+    @dataclasses.dataclass
+    class ClassList:
+        v: ClassVar[List[Foo]] = [Foo(10), Foo(20)]
+
+    d = ClassList()
+    assert {"v": [{"v": 10}, {"v": 20}]} == serde.to_dict(d)
+    assert d == serde.from_dict(ClassList, {})
+
+    # Mutate class variable
+    Enabled.v = 100
+    e = Enabled()
+    assert {"v": 100} == serde.to_dict(e)
+    assert e == serde.from_dict(Enabled, {})
+
+    @serde.serde(serialize_class_var=True)
+    @dataclasses.dataclass
+    class Bar:
+        v: ClassVar[int] = 100
+
+    @serde.serde(serialize_class_var=True)
+    @dataclasses.dataclass
+    class Nested:
+        v: ClassVar[Bar] = Bar()
+
+    f = Nested()
+    assert {"v": {"v": 100}} == serde.to_dict(f)
+    assert f == serde.from_dict(Nested, {})